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..1ed5fa3b0 100644 --- a/.github/workflows/publish-py-sdk.yaml +++ b/.github/workflows/publish-py-sdk.yaml @@ -1,3 +1,6 @@ +# 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 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: ${{ 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.dry_run == 'true' && 'publish-dry-run' || '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: ${{ 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 ee0b08522..37e11477d 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -10,8 +10,8 @@ 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. -6. After approval, the workflow builds/verifies the package, publishes to PyPI, and creates the `py-sdk-v` GitHub Release tag and release. +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 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 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. @@ -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. +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 +115,64 @@ 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 +- 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: + +- 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.