From 364281a66c2ddf6730dacf3625e7696c048cd9cd Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 19 Aug 2026 15:02:38 -0400 Subject: [PATCH 1/4] ci(release): use shared Python publishing actions Migrate production PyPI releases to the commit-pinned sdk-actions configure, validation, approval, and build-and-ship flow. Enable SBOM attestations while preserving stable approval gating and manual prerelease version overrides. --- .github/scripts/generate-release-notes.sh | 120 -------- .github/workflows/publish-py-sdk.yaml | 354 +++++++++------------- docs/publishing.md | 26 +- 3 files changed, 155 insertions(+), 345 deletions(-) delete mode 100755 .github/scripts/generate-release-notes.sh 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/publish-py-sdk.yaml b/.github/workflows/publish-py-sdk.yaml index c02402807..aefe0daee 100644 --- a/.github/workflows/publish-py-sdk.yaml +++ b/.github/workflows/publish-py-sdk.yaml @@ -1,3 +1,6 @@ +# Based on sdk-actions `release/py/turnkey`. +# sdk-actions: {"template":"release/py/turnkey","ref":"71731d4a726df3e3bf42e50c2da4a75842b038dd","version":"1.0.0","params":{"dry_run_environment":"","emoji":":python:","package_name":"braintrust","publish_environment":"pypi-publish","python_version":".tool-versions","slack_channel_var":"","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 on: @@ -28,22 +31,23 @@ on: required: false type: string dry_run: - description: "Validate and build without publishing to PyPI or creating a GitHub Release" + description: "Build and validate without publishing or creating a GitHub Release" required: true type: boolean default: false jobs: - validate: + resolve: 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 + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read 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 }} + commit_sha: ${{ steps.resolve.outputs.commit_sha }} + dry_run: ${{ steps.resolve.outputs.dry_run }} + release_type: ${{ steps.resolve.outputs.release_type }} + version: ${{ steps.resolve.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -54,226 +58,152 @@ jobs: with: cache: true experimental: true - - name: Validate release inputs - id: validate + - name: Resolve release inputs + id: resolve + env: + INPUT_RELEASE_TYPE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type || 'auto' }} + INPUT_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} 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") + VERSION="$INPUT_VERSION" + if [[ -z "$VERSION" ]]; then + VERSION=$(mise exec -- python py/scripts/validate-release.py auto --print-version) 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" - build-and-publish-stable: - needs: validate - if: needs.validate.outputs.release_type == 'stable' && needs.validate.outputs.dry_run != 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: write - id-token: write # Required for PyPI trusted publishing - environment: pypi-publish + RELEASE_TYPE="$INPUT_RELEASE_TYPE" + if [[ "$RELEASE_TYPE" == "auto" ]]; then + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(a|b|rc)[0-9]+$ ]]; then + RELEASE_TYPE=prerelease + else + RELEASE_TYPE=stable + fi + fi - 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 }} + mise exec -- python py/scripts/validate-release.py "$RELEASE_TYPE" \ + --version "$VERSION" --validate-version-only - 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 - 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" + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_name }}" != "py-sdk-v$VERSION" ]]; then + echo "Tag ${{ github.ref_name }} does not match version $VERSION" >&2 + exit 1 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 + { + echo "commit_sha=$(git rev-parse HEAD)" + echo "dry_run=$DRY_RUN" + echo "release_type=$RELEASE_TYPE" + echo "version=$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 }} + configure: + needs: resolve + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + outputs: + 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 }} + pr_list: ${{ steps.configure.outputs.pr_list }} + package: ${{ steps.configure.outputs.package }} + steps: + - name: Configure release + id: configure + uses: braintrustdata/sdk-actions/actions/release/lang/py/configure@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 - }); + sha: ${{ needs.resolve.outputs.commit_sha }} + version: ${{ needs.resolve.outputs.version }} + working_directory: py + tag_format: py-sdk-v{version} + release_type: ${{ needs.resolve.outputs.release_type }} + release_branch: main + pypi_package_name: braintrust + package_label: Braintrust Python SDK + emoji: ':python:' - 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 + validate: + needs: [resolve, configure] + runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: - contents: write - id-token: write # Required for PyPI trusted publishing - + 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: ${{ needs.resolve.outputs.release_type }} + BRAINTRUST_VERSION_OVERRIDE: ${{ needs.resolve.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 }} + - name: Validate release + uses: braintrustdata/sdk-actions/actions/release/lang/py/validate@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: 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 + sha: ${{ needs.resolve.outputs.commit_sha }} + dry_run: ${{ needs.resolve.outputs.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 }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} + build_command: make install-dev verify-build + enforce_release_branch: 'true' + check_tag_unused: ${{ github.event_name != 'push' }} + check_version_unpublished: ${{ github.event_name != 'push' }} + + request-approval: + needs: [resolve, configure, validate] + runs-on: ubuntu-24.04 timeout-minutes: 5 + permissions: {} steps: - - name: Post to Slack on success - uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + - name: Request release approval + uses: braintrustdata/sdk-actions/actions/release/request-approval@71731d4a726df3e3bf42e50c2da4a75842b038dd 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 + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' + sha: ${{ needs.resolve.outputs.commit_sha }} + branch: ${{ needs.configure.outputs.branch }} + on_release_branch: ${{ needs.configure.outputs.on_release_branch }} + commit_message: ${{ needs.configure.outputs.commit_message }} + dry_run: ${{ needs.resolve.outputs.dry_run }} + slack_token: ${{ secrets.SLACK_BOT_TOKEN }} + slack_channel: C0ABHT0SWA2 + + build-and-ship: + needs: [resolve, configure, validate, request-approval] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: ${{ needs.resolve.outputs.release_type == 'stable' && needs.resolve.outputs.dry_run != 'true' && 'pypi-publish' || '' }} + permissions: + contents: write + id-token: write + attestations: write + env: + BRAINTRUST_RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_type }} + BRAINTRUST_VERSION_OVERRIDE: ${{ needs.resolve.outputs.version }} 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: ${{ needs.resolve.outputs.commit_sha }} + working_directory: py + python_version: .tool-versions + build_command: make install-dev verify-build + dry_run: ${{ needs.resolve.outputs.dry_run }} + release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} + github_release: ${{ needs.configure.outputs.github_release }} + release_title: Python SDK v${{ needs.configure.outputs.version }} + version: ${{ needs.configure.outputs.version }} + package_name: braintrust + label: Braintrust Python SDK + 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 }} + pr_list: ${{ needs.configure.outputs.pr_list }} + slack_token: ${{ secrets.SLACK_BOT_TOKEN }} + slack_channel: C0ABHT0SWA2 + emoji: ':python:' diff --git a/docs/publishing.md b/docs/publishing.md index ee0b08522..6690d648a 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -11,7 +11,7 @@ Stable releases use a two-step GitHub Actions flow: a version-bump PR, then an a 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. +6. After approval, the workflow uses the pinned Python release actions from [`braintrustdata/sdk-actions`](https://github.com/braintrustdata/sdk-actions) to build and verify the package, generate and attest a CycloneDX SBOM, publish to PyPI with trusted publishing, and create 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. @@ -28,7 +28,7 @@ Run `Publish Python SDK` with: 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. +Prerelease versions must match `X.Y.Zrc1`, `X.Y.Za1`, or `X.Y.Zb1`. Prereleases publish to the normal PyPI package, but do not create a git tag or GitHub Release. They do not use the stable release PR workflow, do not require a committed version bump, and do not require `pypi-publish` environment approval. 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. @@ -47,21 +47,21 @@ Manual inputs are: - `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 will: +The workflow uses commit-pinned actions from [`braintrustdata/sdk-actions`](https://github.com/braintrustdata/sdk-actions) to: 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: +2. Resolve the package version from the manual `version` input or from `py/src/braintrust/version.py`. +3. Enforce that the selected commit is on `main` and 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. +4. Check PyPI availability and ensure that, for normal release runs, the release tag does not already exist. +5. Generate release notes and post the release approval summary. 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. +7. Generate a CycloneDX SBOM and, for real publishes, create a signed SBOM attestation. +8. If `dry_run=false`, publish to PyPI through OIDC trusted publishing and create the stable GitHub Release with the SBOM attached. -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. +For stable, non-dry-run publishes, the `build-and-ship` job runs in the `pypi-publish` GitHub environment. Configure required reviewers on that environment to approve stable releases before publishing. The job needs `contents: write`, `id-token: write`, and `attestations: write` permissions. ## TestPyPI releases @@ -121,10 +121,10 @@ 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 +- 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: From 424ca7f87b1de339584314d888b9c2298dca6706 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 20 Aug 2026 12:07:16 -0400 Subject: [PATCH 2/4] pr review fixes --- .github/workflows/publish-py-sdk.yaml | 10 ++--- AGENTS.md | 4 +- CONTRIBUTING.md | 4 +- docs/publishing.md | 54 ++++++++++++++++++++++++--- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-py-sdk.yaml b/.github/workflows/publish-py-sdk.yaml index aefe0daee..1ed5fa3b0 100644 --- a/.github/workflows/publish-py-sdk.yaml +++ b/.github/workflows/publish-py-sdk.yaml @@ -1,5 +1,5 @@ -# Based on sdk-actions `release/py/turnkey`. -# sdk-actions: {"template":"release/py/turnkey","ref":"71731d4a726df3e3bf42e50c2da4a75842b038dd","version":"1.0.0","params":{"dry_run_environment":"","emoji":":python:","package_name":"braintrust","publish_environment":"pypi-publish","python_version":".tool-versions","slack_channel_var":"","slack_token_secret":"SLACK_BOT_TOKEN","version_file":"py/src/braintrust/version.py","workflow_name":"Publish Python SDK","working_directory":"py"}} +# 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 @@ -169,13 +169,13 @@ jobs: commit_message: ${{ needs.configure.outputs.commit_message }} dry_run: ${{ needs.resolve.outputs.dry_run }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} - slack_channel: C0ABHT0SWA2 + slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} build-and-ship: needs: [resolve, configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 20 - environment: ${{ needs.resolve.outputs.release_type == 'stable' && needs.resolve.outputs.dry_run != 'true' && 'pypi-publish' || '' }} + environment: ${{ needs.resolve.outputs.dry_run == 'true' && 'publish-dry-run' || 'publish' }} permissions: contents: write id-token: write @@ -205,5 +205,5 @@ jobs: on_release_branch: ${{ needs.configure.outputs.on_release_branch }} pr_list: ${{ needs.configure.outputs.pr_list }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} - slack_channel: C0ABHT0SWA2 + slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} emoji: ':python:' diff --git a/AGENTS.md b/AGENTS.md index e89f08358..c5afe73aa 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. +3. The merge triggers `Publish Python SDK`; the actual PyPI publish job is gated by the `publish` GitHub environment. 4. After approval, the workflow 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 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 gated by the `publish` environment; 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..c4a230bfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 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 `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 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 also gated by 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 6690d648a..37e11477d 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -10,7 +10,7 @@ Stable releases use a two-step GitHub Actions flow: a version-bump PR, then an a 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. +5. The stable publish job waits for approval in the `publish` GitHub environment. 6. After approval, the workflow uses the pinned Python release actions from [`braintrustdata/sdk-actions`](https://github.com/braintrustdata/sdk-actions) to build and verify the package, generate and attest a CycloneDX SBOM, publish to PyPI with trusted publishing, and create 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. @@ -28,7 +28,7 @@ Run `Publish Python SDK` with: 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, but do not create a git tag or GitHub Release. They do not use the stable release PR workflow, do not require a committed version bump, and do not require `pypi-publish` environment approval. +Prerelease versions must match `X.Y.Zrc1`, `X.Y.Za1`, or `X.Y.Zb1`. Prereleases publish to the normal PyPI package, but do not create a git tag or GitHub Release. They do not use the stable release PR workflow or require a committed version bump, but they do require approval in the `publish` GitHub environment. 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. @@ -61,7 +61,7 @@ The workflow uses commit-pinned actions from [`braintrustdata/sdk-actions`](http 7. Generate a CycloneDX SBOM and, for real publishes, create a signed SBOM attestation. 8. If `dry_run=false`, publish to PyPI through OIDC trusted publishing and create the stable GitHub Release with the SBOM attached. -For stable, non-dry-run publishes, the `build-and-ship` job runs in the `pypi-publish` GitHub environment. Configure required reviewers on that environment to approve stable releases before publishing. The job needs `contents: write`, `id-token: write`, and `attestations: write` permissions. +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,7 +115,7 @@ 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: @@ -131,4 +131,48 @@ 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: + +- merged `release/py-sdk-v*` PR and pushed `py-sdk-v*` tag triggers, in addition to manual dispatch +- manual `ref`, `auto` release type, and prerelease version override support +- the `resolve` job that normalizes those trigger modes to a commit SHA, release type, and version +- Braintrust package templating and wheel verification through `make install-dev verify-build` +- `py-sdk-v{version}` tags, `main` branch enforcement, and tag-push repair behavior +- Braintrust-specific release labels, titles, and PR-list 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. From 95cd0cec0f8bfa3efbc2118e4395326a39bcd49f Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 26 Aug 2026 15:01:25 -0400 Subject: [PATCH 3/4] ci(release): use manual SHA-based publishing Align production Python releases with the Ruby SDK turnkey flow: require a committed version at an explicit SHA, gate manual stable and prerelease publishes, and keep the existing Python build verification and tag format. Update the release preparation workflow and publishing documentation; TestPyPI and canary publishing remain unchanged. --- .github/workflows/prepare-release.yml | 4 +- .github/workflows/publish-py-sdk.yaml | 146 +++++++------------------- AGENTS.md | 6 +- CONTRIBUTING.md | 6 +- docs/publishing.md | 78 ++++++-------- 5 files changed, 80 insertions(+), 160 deletions(-) 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 1ed5fa3b0..0ec3783b1 100644 --- a/.github/workflows/publish-py-sdk.yaml +++ b/.github/workflows/publish-py-sdk.yaml @@ -3,99 +3,38 @@ 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: "Build and validate without publishing or creating a GitHub Release" - required: true + description: "Dry run: build without tagging or publishing" type: boolean default: false jobs: - resolve: - 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-24.04 - timeout-minutes: 5 - permissions: - contents: read - outputs: - commit_sha: ${{ steps.resolve.outputs.commit_sha }} - dry_run: ${{ steps.resolve.outputs.dry_run }} - release_type: ${{ steps.resolve.outputs.release_type }} - version: ${{ steps.resolve.outputs.version }} - 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 - with: - cache: true - experimental: true - - name: Resolve release inputs - id: resolve - env: - INPUT_RELEASE_TYPE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type || 'auto' }} - INPUT_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }} - DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} - run: | - VERSION="$INPUT_VERSION" - if [[ -z "$VERSION" ]]; then - VERSION=$(mise exec -- python py/scripts/validate-release.py auto --print-version) - fi - - RELEASE_TYPE="$INPUT_RELEASE_TYPE" - if [[ "$RELEASE_TYPE" == "auto" ]]; then - if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(a|b|rc)[0-9]+$ ]]; then - RELEASE_TYPE=prerelease - else - RELEASE_TYPE=stable - fi - fi - - mise exec -- python py/scripts/validate-release.py "$RELEASE_TYPE" \ - --version "$VERSION" --validate-version-only - - if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_name }}" != "py-sdk-v$VERSION" ]]; then - echo "Tag ${{ github.ref_name }} does not match version $VERSION" >&2 - exit 1 - fi - - { - echo "commit_sha=$(git rev-parse HEAD)" - echo "dry_run=$DRY_RUN" - echo "release_type=$RELEASE_TYPE" - echo "version=$VERSION" - } >> "$GITHUB_OUTPUT" - + # FACT-FIND โ€” derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: - needs: resolve runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -110,38 +49,36 @@ jobs: github_release: ${{ steps.configure.outputs.github_release }} already_published: ${{ steps.configure.outputs.already_published }} notes: ${{ steps.configure.outputs.notes }} - pr_list: ${{ steps.configure.outputs.pr_list }} package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure uses: braintrustdata/sdk-actions/actions/release/lang/py/configure@71731d4a726df3e3bf42e50c2da4a75842b038dd with: - sha: ${{ needs.resolve.outputs.commit_sha }} - version: ${{ needs.resolve.outputs.version }} + version_file: py/src/braintrust/version.py + sha: ${{ inputs.sha }} working_directory: py tag_format: py-sdk-v{version} - release_type: ${{ needs.resolve.outputs.release_type }} - release_branch: main + release_type: ${{ inputs.release_type }} pypi_package_name: braintrust - package_label: Braintrust Python SDK + package_label: braintrust emoji: ':python:' + prev_release: ${{ inputs.prev_release }} validate: - needs: [resolve, configure] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read env: - BRAINTRUST_RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_type }} - BRAINTRUST_VERSION_OVERRIDE: ${{ needs.resolve.outputs.version }} + BRAINTRUST_RELEASE_CHANNEL: ${{ inputs.release_type }} steps: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/py/validate@71731d4a726df3e3bf42e50c2da4a75842b038dd with: - sha: ${{ needs.resolve.outputs.commit_sha }} - dry_run: ${{ needs.resolve.outputs.dry_run }} + sha: ${{ inputs.sha }} + dry_run: ${{ inputs.dry_run }} working_directory: py python_version: .tool-versions release_tag: ${{ needs.configure.outputs.release_tag }} @@ -149,12 +86,9 @@ jobs: notes: ${{ needs.configure.outputs.notes }} already_published: ${{ needs.configure.outputs.already_published }} build_command: make install-dev verify-build - enforce_release_branch: 'true' - check_tag_unused: ${{ github.event_name != 'push' }} - check_version_unpublished: ${{ github.event_name != 'push' }} request-approval: - needs: [resolve, configure, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -163,47 +97,45 @@ jobs: uses: braintrustdata/sdk-actions/actions/release/request-approval@71731d4a726df3e3bf42e50c2da4a75842b038dd with: packages: '{"packages":[${{ needs.configure.outputs.package }}]}' - sha: ${{ needs.resolve.outputs.commit_sha }} + 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: ${{ needs.resolve.outputs.dry_run }} + 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: [resolve, configure, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 20 - environment: ${{ needs.resolve.outputs.dry_run == 'true' && 'publish-dry-run' || 'publish' }} + environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }} permissions: contents: write - id-token: write - attestations: write + id-token: write # OIDC trusted publishing + PEP 740 attestations + attestations: write # signed SBOM attestation env: - BRAINTRUST_RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_type }} - BRAINTRUST_VERSION_OVERRIDE: ${{ needs.resolve.outputs.version }} + BRAINTRUST_RELEASE_CHANNEL: ${{ inputs.release_type }} steps: - name: Build and ship uses: braintrustdata/sdk-actions/actions/release/lang/py/build-and-ship@71731d4a726df3e3bf42e50c2da4a75842b038dd with: - sha: ${{ needs.resolve.outputs.commit_sha }} + sha: ${{ inputs.sha }} working_directory: py python_version: .tool-versions build_command: make install-dev verify-build - dry_run: ${{ needs.resolve.outputs.dry_run }} + 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 }} - release_title: Python SDK v${{ needs.configure.outputs.version }} version: ${{ needs.configure.outputs.version }} package_name: braintrust - label: Braintrust Python SDK + 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 }} - pr_list: ${{ needs.configure.outputs.pr_list }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} emoji: ':python:' diff --git a/AGENTS.md b/AGENTS.md index c5afe73aa..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 `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 gated by the `publish` environment; dry runs are gated by `publish-dry-run`. +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 c4a230bfc..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 `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 also gated by the `publish` environment; dry runs use `publish-dry-run`. +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 37e11477d..cf390f31b 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -4,62 +4,52 @@ 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 `publish` GitHub environment. -6. After approval, the workflow uses the pinned Python release actions from [`braintrustdata/sdk-actions`](https://github.com/braintrustdata/sdk-actions) to build and verify the package, generate and attest a CycloneDX SBOM, publish to PyPI with trusted publishing, and create the `py-sdk-v` GitHub 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, but do not create a git tag or GitHub Release. They do not use the stable release PR workflow or require a committed version bump, but they do require approval in the `publish` GitHub environment. +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` - -Manual inputs are: +`Publish Python SDK` is triggered manually through `workflow_dispatch`. Its inputs are: -- `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`. +- `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`. The workflow uses commit-pinned actions from [`braintrustdata/sdk-actions`](https://github.com/braintrustdata/sdk-actions) to: -1. Check out the release PR merge commit, requested ref, or pushed tag. -2. Resolve the package version from the manual `version` input or from `py/src/braintrust/version.py`. -3. Enforce that the selected commit is on `main` and 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 -4. Check PyPI availability and ensure that, for normal release runs, the release tag does not already exist. -5. Generate release notes and post the release approval summary. -6. Build and verify the package with `make -C py install-dev verify-build`. -7. Generate a CycloneDX SBOM and, for real publishes, create a signed SBOM attestation. -8. 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 requested SHA and read the package version from `py/src/braintrust/version.py`. +2. Determine whether the SHA is on `main`, warning rather than failing if it is not. +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. 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. @@ -119,8 +109,8 @@ Use `dry_run=true` when you want to exercise the release workflow without publis A dry run still: -- validates the selected ref and version -- checks that the release commit is on `main` +- 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` - generates a CycloneDX SBOM @@ -164,12 +154,10 @@ The `# sdk-actions: {...}` header at the top of the workflow records the templat `compare` reports the intentional differences from the turnkey template. Preserve these when updating: -- merged `release/py-sdk-v*` PR and pushed `py-sdk-v*` tag triggers, in addition to manual dispatch -- manual `ref`, `auto` release type, and prerelease version override support -- the `resolve` job that normalizes those trigger modes to a commit SHA, release type, and version -- Braintrust package templating and wheel verification through `make install-dev verify-build` -- `py-sdk-v{version}` tags, `main` branch enforcement, and tag-push repair behavior -- Braintrust-specific release labels, titles, and PR-list notifications +- 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 +- the `@sdk-eng` mention in approval notifications ### Required configuration From 5feaea8bf6d8959842e656ad916a4d23896066af Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 26 Aug 2026 15:09:10 -0400 Subject: [PATCH 4/4] fix(release): enforce main for stable publishes Reject stable release SHAs outside `main` before environment approval so an unmerged commit cannot be permanently published and tagged. Keep prerelease branches warning-only to match the documented release flow. --- .github/workflows/publish-py-sdk.yaml | 1 + docs/publishing.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-py-sdk.yaml b/.github/workflows/publish-py-sdk.yaml index 0ec3783b1..c7fdb54b5 100644 --- a/.github/workflows/publish-py-sdk.yaml +++ b/.github/workflows/publish-py-sdk.yaml @@ -83,6 +83,7 @@ jobs: 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 diff --git a/docs/publishing.md b/docs/publishing.md index cf390f31b..2dd548983 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -44,7 +44,7 @@ If you only want to publish a prerelease build for testing, you can also use `Pu The workflow uses commit-pinned actions from [`braintrustdata/sdk-actions`](https://github.com/braintrustdata/sdk-actions) to: 1. Check out the requested SHA and read the package version from `py/src/braintrust/version.py`. -2. Determine whether the SHA is on `main`, warning rather than failing if it is not. +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`. @@ -157,6 +157,7 @@ The `# sdk-actions: {...}` header at the top of the workflow records the templat - 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