Skip to content

Commit d9e0737

Browse files
authored
ci(release): add stable Python SDK release PR flow (#451)
resolves https://linear.app/braintrustdata/issue/BT-5169/set-up-publish-workflow-environment-rules-for-python-sdk Add a GitHub Actions driven stable release path that creates a version-bump PR from the Prepare Stable Python SDK Release workflow. Merging the release/py-sdk-v<version> PR now triggers Publish Python SDK directly, so tagging and publishing live in one release workflow instead of a separate tag workflow. Gate only stable, non-dry-run PyPI publishes with the pypi-publish environment so the job that calls trusted publishing receives the protected environment context. After approval, the publish workflow builds, publishes to PyPI, and creates the py-sdk-v<version> GitHub Release tag and release. Prereleases continue to use the manual Publish Python SDK workflow without environment approval or a committed version bump; the requested prerelease version is supplied as a workflow input and applied as a build override. Update the release validator and docs for the new process.
1 parent 386831e commit d9e0737

6 files changed

Lines changed: 318 additions & 64 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Starts the stable Python SDK release process by opening a PR that bumps the
2+
# package version. Merge the PR to trigger the approval-gated publish workflow.
3+
name: Prepare Stable Python SDK Release
4+
5+
on:
6+
workflow_dispatch:
7+
inputs:
8+
version:
9+
description: "Stable version to release (e.g., 0.22.0)"
10+
required: true
11+
type: string
12+
13+
permissions:
14+
contents: write
15+
pull-requests: write
16+
17+
jobs:
18+
prepare:
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 10
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
24+
with:
25+
fetch-depth: 0
26+
27+
- name: Validate version format
28+
run: python py/scripts/validate-release.py stable --version "${{ inputs.version }}" --validate-version-only
29+
30+
- name: Generate app token
31+
id: app-token
32+
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
33+
with:
34+
app-id: ${{ secrets.BRAINTRUST_BOT_APP_ID }}
35+
private-key: ${{ secrets.BRAINTRUST_BOT_PRIVATE_KEY }}
36+
37+
- name: Bump version
38+
run: python py/scripts/validate-release.py stable --version "${{ inputs.version }}" --set-version
39+
40+
- name: Compute changeset link
41+
id: changeset
42+
env:
43+
VERSION: ${{ inputs.version }}
44+
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
45+
run: |
46+
LAST_TAG=$(git tag --sort=-version:refname 'py-sdk-v*' | head -n 1 || true)
47+
if [[ -n "$LAST_TAG" ]]; then
48+
URL="${REPO_URL}/compare/${LAST_TAG}...release/py-sdk-v${VERSION}"
49+
echo "body=Changeset since [\`${LAST_TAG}\`](${REPO_URL}/releases/tag/${LAST_TAG}): ${URL}" >> "$GITHUB_OUTPUT"
50+
else
51+
URL="${REPO_URL}/commits/release/py-sdk-v${VERSION}"
52+
echo "body=Changeset: ${URL}" >> "$GITHUB_OUTPUT"
53+
fi
54+
55+
- name: Create pull request
56+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
57+
with:
58+
token: ${{ steps.app-token.outputs.token }}
59+
add-paths: py/src/braintrust/version.py
60+
branch: release/py-sdk-v${{ inputs.version }}
61+
commit-message: "chore: release Python SDK v${{ inputs.version }}"
62+
title: "chore: release Python SDK v${{ inputs.version }}"
63+
body: |
64+
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.
65+
66+
${{ steps.changeset.outputs.body }}

.github/workflows/publish-py-sdk.yaml

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
name: Publish Python SDK
22

33
on:
4+
pull_request:
5+
types: [closed]
6+
branches: [main]
7+
push:
8+
tags:
9+
- "py-sdk-v*"
410
workflow_dispatch:
511
inputs:
612
ref:
@@ -15,7 +21,12 @@ on:
1521
options:
1622
- stable
1723
- prerelease
24+
- auto
1825
default: stable
26+
version:
27+
description: "Version to publish for prereleases (e.g. 0.22.0rc1). Stable releases read version.py."
28+
required: false
29+
type: string
1930
dry_run:
2031
description: "Validate and build without publishing to PyPI or creating a GitHub Release"
2132
required: true
@@ -24,6 +35,7 @@ on:
2435

2536
jobs:
2637
validate:
38+
if: github.event_name != 'pull_request' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/py-sdk-v'))
2739
runs-on: ubuntu-latest
2840
timeout-minutes: 10
2941
outputs:
@@ -35,7 +47,7 @@ jobs:
3547
steps:
3648
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3749
with:
38-
ref: ${{ github.event.inputs.ref }}
50+
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || (github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.ref) }}
3951
fetch-depth: 0
4052
- name: Set up mise
4153
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
@@ -45,13 +57,98 @@ jobs:
4557
- name: Validate release inputs
4658
id: validate
4759
run: |
48-
mise exec -- python py/scripts/validate-release.py \
49-
"${{ github.event.inputs.release_type }}" \
50-
--github-output "$GITHUB_OUTPUT"
51-
echo "dry_run=${{ github.event.inputs.dry_run }}" >> "$GITHUB_OUTPUT"
60+
VALIDATE_ARGS=("${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_type || 'auto' }}" --github-output "$GITHUB_OUTPUT")
61+
VERSION_INPUT="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || '' }}"
62+
if [[ -n "$VERSION_INPUT" ]]; then
63+
VALIDATE_ARGS+=(--version "$VERSION_INPUT")
64+
fi
65+
if [[ "${{ github.event_name }}" == "push" ]]; then
66+
VALIDATE_ARGS+=(--allow-existing-tag)
67+
fi
68+
mise exec -- python py/scripts/validate-release.py "${VALIDATE_ARGS[@]}"
69+
echo "dry_run=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run || 'false' }}" >> "$GITHUB_OUTPUT"
70+
71+
build-and-publish-stable:
72+
needs: validate
73+
if: needs.validate.outputs.release_type == 'stable' && needs.validate.outputs.dry_run != 'true'
74+
runs-on: ubuntu-latest
75+
timeout-minutes: 20
76+
permissions:
77+
contents: write
78+
id-token: write # Required for PyPI trusted publishing
79+
environment: pypi-publish
80+
81+
env:
82+
COMMIT_SHA: ${{ needs.validate.outputs.commit_sha }}
83+
DRY_RUN: ${{ needs.validate.outputs.dry_run }}
84+
RELEASE_TAG: ${{ needs.validate.outputs.release_tag }}
85+
RELEASE_TYPE: ${{ needs.validate.outputs.release_type }}
86+
VERSION: ${{ needs.validate.outputs.version }}
87+
88+
steps:
89+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
90+
with:
91+
ref: ${{ env.COMMIT_SHA }}
92+
fetch-depth: 0
93+
- name: Set up mise
94+
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
95+
with:
96+
cache: true
97+
experimental: true
98+
- name: Build and verify
99+
env:
100+
BRAINTRUST_RELEASE_CHANNEL: ${{ env.RELEASE_TYPE }}
101+
BRAINTRUST_VERSION_OVERRIDE: ${{ env.VERSION }}
102+
run: |
103+
mise exec -- make -C py install-dev verify-build
104+
- name: Upload build artifacts
105+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
106+
with:
107+
name: python-sdk-dist
108+
path: py/dist/
109+
retention-days: 5
110+
- name: Publish to PyPI
111+
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
112+
with:
113+
packages-dir: py/dist/
114+
115+
- name: Create local release tag
116+
run: |
117+
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
118+
git tag "$RELEASE_TAG" "$COMMIT_SHA"
119+
fi
120+
121+
# Create GitHub Release
122+
- name: Generate release notes
123+
id: release_notes
124+
run: |
125+
RELEASE_NOTES=$(.github/scripts/generate-release-notes.sh "${{ env.RELEASE_TAG }}" "py/")
126+
echo "notes<<EOF" >> $GITHUB_OUTPUT
127+
echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT
128+
echo "EOF" >> $GITHUB_OUTPUT
129+
echo "release_name=Python SDK v${VERSION}" >> $GITHUB_OUTPUT
130+
131+
- name: Create GitHub Release
132+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
133+
env:
134+
RELEASE_NOTES: ${{ steps.release_notes.outputs.notes }}
135+
RELEASE_NAME: ${{ steps.release_notes.outputs.release_name }}
136+
with:
137+
script: |
138+
await github.rest.repos.createRelease({
139+
owner: context.repo.owner,
140+
repo: context.repo.repo,
141+
tag_name: process.env.RELEASE_TAG,
142+
target_commitish: process.env.COMMIT_SHA,
143+
name: process.env.RELEASE_NAME,
144+
body: process.env.RELEASE_NOTES,
145+
draft: false,
146+
prerelease: false
147+
});
52148
53149
build-and-publish:
54150
needs: validate
151+
if: needs.validate.result == 'success' && (needs.validate.outputs.release_type != 'stable' || needs.validate.outputs.dry_run == 'true')
55152
runs-on: ubuntu-latest
56153
timeout-minutes: 20
57154
permissions:
@@ -76,6 +173,9 @@ jobs:
76173
cache: true
77174
experimental: true
78175
- name: Build and verify
176+
env:
177+
BRAINTRUST_RELEASE_CHANNEL: ${{ env.RELEASE_TYPE }}
178+
BRAINTRUST_VERSION_OVERRIDE: ${{ env.VERSION }}
79179
run: |
80180
mise exec -- make -C py install-dev verify-build
81181
- name: Upload build artifacts
@@ -91,7 +191,10 @@ jobs:
91191
packages-dir: py/dist/
92192

93193
- name: Create local release tag
94-
run: git tag "$RELEASE_TAG" "$COMMIT_SHA"
194+
run: |
195+
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
196+
git tag "$RELEASE_TAG" "$COMMIT_SHA"
197+
fi
95198
96199
# Create GitHub Release
97200
- name: Generate release notes
@@ -128,8 +231,8 @@ jobs:
128231
echo "Dry run completed for $RELEASE_TAG from $COMMIT_SHA"
129232
130233
notify-success:
131-
needs: [validate, build-and-publish]
132-
if: always() && needs.build-and-publish.result == 'success'
234+
needs: [validate, build-and-publish-stable, build-and-publish]
235+
if: always() && (needs.build-and-publish-stable.result == 'success' || needs.build-and-publish.result == 'success')
133236
runs-on: ubuntu-latest
134237
timeout-minutes: 5
135238
steps:
@@ -149,11 +252,11 @@ jobs:
149252
- type: "section"
150253
text:
151254
type: "mrkdwn"
152-
text: "${{ needs.validate.outputs.dry_run == 'true' && format('*Mode:* dry run\n*Release type:* {0}\n*Version:* {1}\n*Ref:* {2}\n\n<{3}/{4}/actions/runs/{5}|View Run>', needs.validate.outputs.release_type, needs.validate.outputs.version, github.event.inputs.ref, github.server_url, github.repository, github.run_id) || format('*Release type:* {0}\n*Version:* {1}\n*Package:* <https://pypi.org/project/braintrust/|braintrust>\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) }}"
255+
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:* <https://pypi.org/project/braintrust/|braintrust>\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) }}"
153256
154257
notify-failure:
155-
needs: [validate, build-and-publish]
156-
if: always() && (needs.validate.result == 'failure' || needs.build-and-publish.result == 'failure')
258+
needs: [validate, build-and-publish-stable, build-and-publish]
259+
if: always() && (needs.validate.result == 'failure' || needs.build-and-publish-stable.result == 'failure' || needs.build-and-publish.result == 'failure')
157260
runs-on: ubuntu-latest
158261
timeout-minutes: 5
159262
steps:
@@ -173,4 +276,4 @@ jobs:
173276
- type: "section"
174277
text:
175278
type: "mrkdwn"
176-
text: "*Release type:* ${{ github.event.inputs.release_type }}\n*Ref:* ${{ github.event.inputs.ref }}\n*Commit:* ${{ needs.validate.outputs.commit_sha || github.sha }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"
279+
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>"

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,21 @@ Caveat:
287287

288288
Avoid editing `py/src/braintrust/version.py` while also running build commands.
289289

290+
## Publishing Notes
291+
292+
See `docs/publishing.md` for the full release playbook.
293+
294+
Stable Python SDK releases:
295+
296+
1. Run the `Prepare Stable Python SDK Release` workflow with a stable `X.Y.Z` version.
297+
2. Review and merge the generated `release/py-sdk-v<version>` PR.
298+
3. The merge triggers `Publish Python SDK`; the actual PyPI publish job is gated by the `pypi-publish` GitHub environment.
299+
4. After approval, the workflow publishes to PyPI and creates the `py-sdk-v<version>` GitHub Release tag and release.
300+
301+
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.
302+
303+
Do not create or push release tags locally.
304+
290305
## Dependency Pinning
291306

292307
All nox session dependency pins are centralized in `py/pyproject.toml`:

CONTRIBUTING.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,22 @@ Main workflows:
250250
- `checks.yaml`: merged SDK checks workflow, including lint, pinned-action validation, the Python test matrix, wheel build, and the `checks-passed` required-check aggregator
251251
- `langchain-py-test.yaml`: LangChain integration tests
252252
- `adk-py-test.yaml`: ADK integration tests
253-
- `publish-py-sdk.yaml`: PyPI release
253+
- `prepare-release.yml`: stable Python SDK version-bump PR creation
254+
- `publish-py-sdk.yaml`: PyPI release, including stable release PR merges
254255
- `test-publish-py-sdk.yaml`: TestPyPI release validation
255256

256257
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.
257258

259+
## Publishing
260+
261+
See `docs/publishing.md` for the full Python SDK publishing playbook.
262+
263+
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<version>` 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<version>` GitHub Release tag and release.
264+
265+
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.
266+
267+
Do not create or push release tags locally.
268+
258269
## Submitting Changes
259270

260271
1. Make your change in the narrowest relevant area.

0 commit comments

Comments
 (0)