diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml index d602da2..5b8fb34 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -1,11 +1,57 @@ -# Placeholder so the release-py.yml workflow_dispatch trigger is registered on the -# default branch. This lets the full workflow be dispatched from its feature branch -# ("Use workflow from: ") before merge. The functional workflow — which -# publishes the bt-publishing-test dummy PyPI package end-to-end — replaces this -# stub when the Python release PR lands. +# +# Releases bt-publishing-test — a dummy PyPI package used to validate Braintrust +# release workflow tooling end-to-end. This does NOT release any real Braintrust +# Python SDK. +# +# This workflow serves two purposes: +# 1. End-to-end testing of the composite actions in actions/release/ +# 2. Reference implementation of the Python-specific release template +# +# Generic steps are provided by composite actions in actions/release/. +# Only the sections marked with inline comments need to change for other packages. +# +# ───────────────────────────────────────────────────────────────────────────── +# SETUP REQUIREMENTS +# ───────────────────────────────────────────────────────────────────────────── +# +# GitHub Environments (repo Settings → Environments): +# publish +# - Required reviewers: sdk-eng team or named maintainers +# - Prevent self-review: enabled +# - Deployment branches: main only +# publish-dry-run +# - Required reviewers: at least yourself (to test the approval gate) +# - Allow administrators to bypass: enabled (for testing flexibility) +# - Deployment branches: no restriction +# +# Repository Variables (Settings → Secrets and variables → Variables): +# SLACK_SDK_RELEASE_CHANNEL Slack channel ID for release notifications +# +# Repository Secrets (Settings → Secrets and variables → Secrets): +# SLACK_BOT_TOKEN Org-level secret — Brainbot Slack app token +# Must be invited to SLACK_SDK_RELEASE_CHANNEL +# +# PyPI Trusted Publishing (OIDC — no token needed): +# Configure for your package at pypi.org → project → Settings → Publishing, +# or pre-register a "pending publisher" before the first publish: +# Owner: braintrustdata (or your org) +# Repository: sdk-actions (or your SDK repo) +# Workflow: release-py.yml (or your workflow filename) +# Environment: publish +# The publish job needs id-token: write for OIDC + PEP 740 attestations. +# +# ───────────────────────────────────────────────────────────────────────────── + name: Release Python (bt-publishing-test) on: + # Auto dry-run on PRs that touch the generated actions or the workflows, to + # smoke-test the actions end-to-end. (A template edited without regenerating + # is caught separately by the CI check-generated job, which runs on every PR.) + pull_request: + paths: + - 'actions/**' + - '.github/workflows/**' workflow_dispatch: inputs: _instructions: @@ -21,6 +67,8 @@ on: description: "Anchor for release notes: a tag name or commit SHA. If SHA, notes will be empty." type: string required: false + # bt-publishing-test: fixed SHA anchor prevents indefinite changelog accumulation. + # Update this when you want to reset the notes baseline. default: '58a397193105516446c3042361913655bcece509' dry_run: description: "Dry run: Build without tagging or publishing" @@ -28,10 +76,166 @@ on: default: false jobs: - placeholder: + bump: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} + outputs: + version: ${{ steps.bump.outputs.version }} + steps: + # bt-publishing-test: derives next version by querying PyPI and incrementing the patch. + # Falls back to 0.0.1 on first publish (project not found). Remove this job + # in real SDK workflows; version comes from the version bump PR. + - name: Bump version + id: bump + run: | + # Project may not exist yet (first release / new name); default to 0.0.0 + # so the first publish is 0.0.1. + CURRENT=$(curl -sf https://pypi.org/pypi/bt-publishing-test/json 2>/dev/null | jq -r '.info.version // empty' || true) + CURRENT=${CURRENT:-0.0.0} + IFS='.' read -r major minor patch <<< "$CURRENT" + echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT + + validate: + needs: bump + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + outputs: + release_tag: ${{ steps.validate-release.outputs.release_tag }} + prev_release: ${{ steps.validate-release.outputs.prev_release }} + branch: ${{ steps.validate-release.outputs.branch }} + on_release_branch: ${{ steps.validate-release.outputs.on_release_branch }} + commit_message: ${{ steps.validate-release.outputs.commit_message }} steps: - - name: Placeholder - run: echo "Placeholder — registers workflow_dispatch. The functional release-py.yml lands in the Python release PR." + # bt-publishing-test: checkout required to resolve local ./actions/... references. + # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references + # and do not need this step. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Validate release + id: validate-release + uses: ./actions/release/lang/py/validate + with: + # bt-publishing-test: version passed directly from bump job — no version_file read needed. + # In a real Python project, drop `version`; it is read from version_file. + version: ${{ needs.bump.outputs.version }} + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + working_directory: test/release/py + python_version: test/release/py/.python-version # exercises version-file sourcing (vs explicit version) + package_name: bt-publishing-test # exercises the PyPI-availability fail-fast check + + prepare: + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write # required for releases/generate-notes API + outputs: + pr_list: ${{ steps.prepare.outputs.pr_list }} + notes: ${{ steps.prepare.outputs.notes }} + steps: + # bt-publishing-test: checkout required because actions are referenced locally (./actions/...). + # Real SDK workflows reference actions externally (braintrustdata/sdk-actions/...@sha) + # and do not need this step. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Prepare release + id: prepare + uses: ./actions/release/prepare + with: + release_tag: ${{ needs.validate.outputs.release_tag }} + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + # On PR auto dry-runs, anchor notes to the head SHA: prepare treats a + # full SHA as "notes unavailable" (no tag range), so the changelog + # doesn't accumulate in the step summary on every PR. + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.validate.outputs.prev_release }} + + notify-pending: + needs: [validate, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + # bt-publishing-test: checkout required for local action resolution (see prepare job comment) + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Notify release pending + uses: ./actions/release/notify-pending + with: + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + release_tag: ${{ needs.validate.outputs.release_tag }} + prev_release: ${{ needs.validate.outputs.prev_release }} + branch: ${{ needs.validate.outputs.branch }} + on_release_branch: ${{ needs.validate.outputs.on_release_branch }} + commit_message: ${{ needs.validate.outputs.commit_message }} + pr_list: ${{ needs.prepare.outputs.pr_list }} + notes: ${{ needs.prepare.outputs.notes }} + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). + # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression + # must be the trailing '' branch, not the "true" value. + slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} + slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} + # slack_mention: '@sdk-eng' + label: 'bt-publishing-test' # distinguishes this from other packages in this repo + emoji: ':python:' + + publish: + needs: [bump, validate, prepare, notify-pending] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + # No environment on PR dry-runs (avoids the approval gate AND the accumulating + # deployment records); the gated environments apply only to manual dispatch. + # Note the structure: empty must be the trailing `|| ''` branch, since GHA's + # `cond && '' || X` would fall through to X (the '' is falsy). + environment: >- + ${{ github.event_name != 'pull_request' + && (inputs.dry_run && 'publish-dry-run' || 'publish') + || '' }} + permissions: + contents: write + id-token: write # OIDC trusted publishing + PEP 740 attestations + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha || github.event.pull_request.head.sha }} + fetch-depth: 0 + + # bt-publishing-test: write the run-number version into version.py. The publish job + # checks out fresh, so the file still reflects the placeholder version. + # Real SDK workflows omit this — the version is committed in the bump PR. + - name: Apply version to version.py + env: + VERSION: ${{ needs.bump.outputs.version }} + run: | + printf 'VERSION = "%s"\n' "$VERSION" > test/release/py/src/bt_publishing_test/version.py + + - name: Publish + uses: ./actions/release/lang/py/publish + with: + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + checkout: 'false' # bt-publishing-test: skip internal checkout — version patching above must survive into the build + working_directory: test/release/py + python_version: test/release/py/.python-version + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + release_tag: ${{ needs.validate.outputs.release_tag }} + github_release: 'false' # bt-publishing-test: omit GitHub release to avoid tag/release pollution from repeated test runs + version: ${{ needs.bump.outputs.version }} + package_name: bt-publishing-test + label: 'bt-publishing-test' # distinguishes this from other packages in this repo + notes: ${{ needs.prepare.outputs.notes }} + prev_release: ${{ needs.validate.outputs.prev_release }} + branch: ${{ needs.validate.outputs.branch }} + on_release_branch: ${{ needs.validate.outputs.on_release_branch }} + pr_list: ${{ needs.prepare.outputs.pr_list }} + # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). + # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression + # must be the trailing '' branch, not the "true" value. + slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} + slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} + # slack_mention: '@sdk-eng' + # failure_slack_mention: '@sdk-eng' + emoji: ':python:' diff --git a/README.md b/README.md index 05924db..c15f417 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Shared GitHub actions and workflows for Braintrust SDK repositories. 1. **Copy the canonical template** for your language into your repo's `.github/workflows/`: - [`release-js.yml`](.github/workflows/release-js.yml) + - [`release-py.yml`](.github/workflows/release-py.yml) - [`release-ruby.yml`](.github/workflows/release-ruby.yml) 2. **Adapt the marked sections** — version source, gem name, working directory, and so on. The template's comments flag exactly what changes per repo; @@ -35,6 +36,8 @@ pulls in everything it needs. | `release/lang/ruby/validate` | Check out the SHA, set up Ruby, read the version, validate the release (tag/branch/metadata), lint + build | | `release/lang/js/publish` | Publish the package to npm (OIDC trusted publishing), create the GitHub release, and notify | | `release/lang/js/validate` | Check out the SHA, set up Node + package manager, read the version, validate the release (channel/tag/branch/metadata), build | +| `release/lang/py/publish` | Publish the package to PyPI (OIDC trusted publishing + PEP 740 attestations), create the GitHub release, and notify | +| `release/lang/py/validate` | Check out the SHA, set up uv + Python, read the version, validate the release (tag/branch/metadata, PyPI availability), build | | `release/notify-pending` | Post the pre-approval job summary and Slack notification | | `release/prepare` | Fetch the PR list and release notes | diff --git a/actions/release/lang/py/publish/action.yml b/actions/release/lang/py/publish/action.yml new file mode 100644 index 0000000..ffce1e8 --- /dev/null +++ b/actions/release/lang/py/publish/action.yml @@ -0,0 +1,454 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +name: Publish Python Package +description: > + Full Python package release: checks out the SHA, sets up uv and Python, + builds the sdist + wheel, publishes to PyPI (OIDC Trusted Publishing with PEP + 740 attestations), creates a GitHub release, and sends a Slack completion + notification. Covers the standard publish sequence for Braintrust PyPI packages. + +inputs: + sha: + description: "Commit SHA to check out and release" + required: true + checkout: + description: "Check out the SHA before publishing. Set to false when the caller has already checked out and patched files." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + python_version: + description: > + Python version (e.g. 3.13), or a path to a version file + (.python-version, .tool-versions) relative to the repo root. + required: true + build_command: + description: "Override build command. When empty, `uv build` is used." + required: false + default: '' + dry_run: + description: "Skip PyPI upload, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. py-0.3.2)" + required: true + version: + description: "The package version — used to build the PyPI link in notifications" + required: false + default: '' + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + pypi: + description: "Whether to publish to PyPI" + required: false + default: 'true' + attestations: + description: "Whether to publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write." + required: false + default: 'true' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + package_name: + description: "PyPI package name — used to build the PyPI link in notifications" + required: false + default: '' + label: + description: > + Display name for release notifications (e.g. the package name). When set, it + becomes the notification subject instead of the repository — useful when a repo + publishes multiple packages. Defaults to the repository. + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':package:' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Resolve Python version source + id: py-src + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python_version }} + run: | + # python_version is either an explicit version (e.g. 3.13) or a path to a + # version file (.python-version / .tool-versions). Route by file existence. + if [ -n "$PYTHON_VERSION" ] && [ -f "$PYTHON_VERSION" ]; then + case "$PYTHON_VERSION" in + *.tool-versions) SPEC=$(awk '/^python /{print $2; exit}' "$PYTHON_VERSION") ;; + *) SPEC=$(head -1 "$PYTHON_VERSION" | tr -d '[:space:]') ;; + esac + else + SPEC="$PYTHON_VERSION" + fi + echo "spec=$SPEC" >> "$GITHUB_OUTPUT" + + - name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + # Installs this Python and exports UV_PYTHON for the job, so subsequent uv + # commands (build, read-version) use it. + python-version: ${{ steps.py-src.outputs.spec }} + + - name: Build distribution + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + BUILD_COMMAND: ${{ inputs.build_command }} + run: | + rm -rf dist + if [ -n "$BUILD_COMMAND" ]; then + echo "Running custom build: $BUILD_COMMAND" + eval "$BUILD_COMMAND" + else + uv build + fi + uvx twine check dist/* + + - name: Publish to PyPI + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PYPI_ENABLED: ${{ inputs.pypi }} + DRY_RUN: ${{ inputs.dry_run }} + ATTESTATIONS: ${{ inputs.attestations }} + run: | + if [ "$PYPI_ENABLED" != "true" ]; then + echo "PyPI publishing disabled; skipping." + exit 0 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: artifacts built and twine-checked; not uploaded to PyPI." + exit 0 + fi + # --trusted-publishing always: require OIDC (fail loudly rather than fall back + # to token auth). + ARGS=(--trusted-publishing always) + if [ "$ATTESTATIONS" = "true" ]; then + # uv uploads attestations but does not generate them; sign first (Sigstore, + # ambient GitHub OIDC) so the *.publish.attestation files exist for upload. + uvx pypi-attestations sign dist/*.whl dist/*.tar.gz + else + ARGS+=(--no-attestations) + fi + uv publish "${ARGS[@]}" + + - name: Create GitHub release + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + ENABLED: ${{ inputs.github_release }} + DRY_RUN: ${{ inputs.dry_run }} + TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + NOTES_B64: ${{ inputs.notes }} + SHA: ${{ inputs.sha }} + run: | + if [ "$ENABLED" != "true" ]; then + echo "GitHub release disabled; skipping." + exit 0 + fi + # Release name defaults to the tag when no explicit title is provided. + TITLE="${RELEASE_TITLE:-$TAG}" + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $TAG (title: $TITLE)" + echo "--- Release notes preview ---" + echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "(unavailable)" + else + echo "$NOTES_B64" | base64 -d 2>/dev/null > /tmp/release-notes.md + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-notes.md \ + --target "$SHA" + fi + + - name: Build PyPI links + id: links + shell: bash + env: + PACKAGE_NAME: ${{ inputs.package_name }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + LINKS='[]' + if [ "$DRY_RUN" != "true" ] && [ -n "$PACKAGE_NAME" ] && [ -n "$VERSION" ]; then + LINKS="[{\"label\":\"PyPI\",\"url\":\"https://pypi.org/project/$PACKAGE_NAME/$VERSION/\"}]" + fi + echo "links=$LINKS" >> "$GITHUB_OUTPUT" + + - name: Post release summary + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + PREV_RELEASE: ${{ inputs.prev_release }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + DRY_RUN: ${{ inputs.dry_run }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" + + # Subject is the package label when set (disambiguates repos that publish + # multiple packages); otherwise the repository. The repo is still present in + # every link below, so it is never lost. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + if [ "$DRY_RUN" = "true" ]; then + echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + else + echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> Released from non-release branch: [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "**Branch:** [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + echo "**Diff:** [$PREV_RELEASE...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published to" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "- [View dry run]($RUN_URL)" >> $GITHUB_STEP_SUMMARY + else + echo "- [GitHub Release]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + echo "$LINKS_JSON" | jq -r '.[] | "- [\(.label)](\(.url))"' >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Build published message + id: message-published + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + EMOJI: ${{ inputs.emoji }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + PREV_RELEASE: ${{ inputs.prev_release }} + DRY_RUN: ${{ inputs.dry_run }} + SLACK_MENTION: ${{ inputs.slack_mention }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR_LIST_RAW: ${{ inputs.pr_list }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + + # Subject is the package label when set, else the repository (see summary step). + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + DIFF_PART="" + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + DIFF_PART="<$DIFF_URL|${PREV_RELEASE}...$TAG> · " + fi + + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* complete" + TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + else + TEXT="$EMOJI *$SUBJECT $TAG* published" + fi + + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" + TEXT="$TEXT\n> ⚠️ NOT on release branch: <$BRANCH_URL|$BRANCH>" + fi + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" + else + SLACK_LINKS="${DIFF_PART}<$RELEASE_URL|View release>" + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + EXTRA=$(echo "$LINKS_JSON" | jq -r '[.[] | "<\(.url)|\(.label)>"] | join(" · ")') + SLACK_LINKS="$SLACK_LINKS · $EXTRA" + fi + TEXT="$TEXT\n$SLACK_LINKS" + fi + + if [ -n "$PR_LIST" ]; then + TEXT="$TEXT\n$PR_LIST" + fi + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT\n$SLACK_MENTION" + fi + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "Slack notification failed: $RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Dump Python environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml ===" + head -40 pyproject.toml 2>/dev/null || echo "(no pyproject.toml)" + + - name: Build failure message + if: ${{ failure() }} + id: message-failure + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + STEP: publish + SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> + run: | + # Subject is the package label when set, else the repository. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + TEXT=":x: *$SUBJECT $RELEASE_TAG* failed at $STEP" + TEXT="$TEXT\nInitiated by: $ACTOR_LINK" + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT $SLACK_MENTION" + fi + + TEXT="$TEXT\n<$RUN_URL|View failed run>" + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failure.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "Slack notification failed: $RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/lang/py/validate/action.yml b/actions/release/lang/py/validate/action.yml new file mode 100644 index 0000000..f5036b9 --- /dev/null +++ b/actions/release/lang/py/validate/action.yml @@ -0,0 +1,232 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +name: Validate Python Release +description: > + Checks out the release SHA, sets up uv and Python, reads the package + version, validates the release metadata (tag existence, branch, commit + metadata) and that the version is not already on PyPI, then builds to confirm + the package is publishable before the approval gate. + +inputs: + sha: + description: "Commit SHA being released" + required: true + dry_run: + description: "Skip tag existence failure in dry runs" + required: false + default: 'false' + version: + description: > + Explicit version override. If provided, the version is not read from + version_file. Use when the version is computed externally (e.g. bt-publishing-test + auto-bump, or an ephemeral prerelease). + required: false + default: '' + version_file: + description: > + Path (repo-root-relative) to a Python module exposing VERSION (e.g. + py/pkg/version.py). Read when version is not overridden. + required: false + default: '' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + python_version: + description: > + Python version (e.g. 3.13), or a path to a version file + (.python-version, .tool-versions) relative to the repo root. + required: true + package_name: + description: > + PyPI package name (matches the publish action's input). When set, validate + fails fast if this version is already published (covers prereleases, which + aren't git-tagged). Leave empty to skip. + required: false + default: '' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + required: false + default: 'py-{version}' + build_command: + description: "Override build command. When empty, `uv build` is used." + required: false + default: '' +outputs: + release_tag: + description: "The release tag (e.g. py-0.3.2)" + value: ${{ steps.validate.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.validate.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.validate.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an expected release branch" + value: ${{ steps.validate.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.validate.outputs.commit_message }} + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Resolve Python version source + id: py-src + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python_version }} + run: | + # python_version is either an explicit version (e.g. 3.13) or a path to a + # version file (.python-version / .tool-versions). Route by file existence. + if [ -n "$PYTHON_VERSION" ] && [ -f "$PYTHON_VERSION" ]; then + case "$PYTHON_VERSION" in + *.tool-versions) SPEC=$(awk '/^python /{print $2; exit}' "$PYTHON_VERSION") ;; + *) SPEC=$(head -1 "$PYTHON_VERSION" | tr -d '[:space:]') ;; + esac + else + SPEC="$PYTHON_VERSION" + fi + echo "spec=$SPEC" >> "$GITHUB_OUTPUT" + + - name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + # Installs this Python and exports UV_PYTHON for the job, so subsequent uv + # commands (build, read-version) use it. + python-version: ${{ steps.py-src.outputs.spec }} + + - name: Read version + id: read-version + shell: bash + env: + VERSION_OVERRIDE: ${{ inputs.version }} + VERSION_FILE: ${{ inputs.version_file }} + run: | + if [ -n "$VERSION_OVERRIDE" ]; then + echo "version=$VERSION_OVERRIDE" >> "$GITHUB_OUTPUT" + else + # exec the version file in an isolated namespace (no package import needed), + # matching how the SDKs expose VERSION in a standalone version.py. + VERSION=$(uv run --no-project python -c "import runpy,sys; print(runpy.run_path(sys.argv[1])['VERSION'])" "$VERSION_FILE") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + fi + + - name: Check PyPI version availability + shell: bash + env: + PACKAGE_NAME: ${{ inputs.package_name }} + VERSION: ${{ steps.read-version.outputs.version }} + run: | + if [ -z "$PACKAGE_NAME" ]; then + echo "No package_name provided; skipping PyPI availability check." + exit 0 + fi + CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + --retry 3 --retry-connrefused --max-time 30 \ + "https://pypi.org/pypi/$PACKAGE_NAME/$VERSION/json") + case "$CODE" in + 200) + echo "Error: $PACKAGE_NAME $VERSION already exists on PyPI — has the version been bumped?" + exit 1 + ;; + 404) + echo "$PACKAGE_NAME $VERSION is available on PyPI." + ;; + *) + echo "Error: unexpected response from PyPI (HTTP $CODE) checking $PACKAGE_NAME $VERSION;" + echo "cannot confirm the version is unpublished — aborting rather than risk a bad release." + exit 1 + ;; + esac + + - name: Validate SHA format + shell: bash + env: + SHA: ${{ inputs.sha }} + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "Error: sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + + - name: Validate release + id: validate + shell: bash + env: + VERSION: ${{ steps.read-version.outputs.version }} + TAG_FORMAT: ${{ inputs.tag_format }} + DRY_RUN: ${{ inputs.dry_run }} + RELEASE_BRANCH: 'main' + SHA: ${{ inputs.sha }} + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + if git rev-parse "$TAG" >/dev/null 2>&1; then + if [ "$DRY_RUN" = "true" ]; then + echo "Warning: Tag $TAG already exists — skipping in dry run" + else + echo "Error: Tag $TAG already exists — has the version been bumped?" + exit 1 + fi + fi + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then + ON_RELEASE_BRANCH=true + break + fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + echo "release_tag=$TAG" >> $GITHUB_OUTPUT + echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT + echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT + echo "Validated $TAG @ $SHA ($COMMIT_MSG)" + + - name: Build distribution + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + BUILD_COMMAND: ${{ inputs.build_command }} + run: | + rm -rf dist + if [ -n "$BUILD_COMMAND" ]; then + echo "Running custom build: $BUILD_COMMAND" + eval "$BUILD_COMMAND" + else + uv build + fi + uvx twine check dist/* + + - name: Dump Python environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml ===" + head -40 pyproject.toml 2>/dev/null || echo "(no pyproject.toml)" diff --git a/templates/actions/release/lang/py/publish.yml.erb b/templates/actions/release/lang/py/publish.yml.erb new file mode 100644 index 0000000..94044f6 --- /dev/null +++ b/templates/actions/release/lang/py/publish.yml.erb @@ -0,0 +1,125 @@ +name: Publish Python Package +description: > + Full Python package release: checks out the SHA, sets up uv and Python, + builds the sdist + wheel, publishes to PyPI (OIDC Trusted Publishing with PEP + 740 attestations), creates a GitHub release, and sends a Slack completion + notification. Covers the standard publish sequence for Braintrust PyPI packages. + +inputs: + sha: + description: "Commit SHA to check out and release" + required: true + checkout: + description: "Check out the SHA before publishing. Set to false when the caller has already checked out and patched files." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + python_version: + description: > + Python version (e.g. 3.13), or a path to a version file + (.python-version, .tool-versions) relative to the repo root. + required: true + build_command: + description: "Override build command. When empty, `uv build` is used." + required: false + default: '' + dry_run: + description: "Skip PyPI upload, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. py-0.3.2)" + required: true + version: + description: "The package version — used to build the PyPI link in notifications" + required: false + default: '' + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + pypi: + description: "Whether to publish to PyPI" + required: false + default: 'true' + attestations: + description: "Whether to publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write." + required: false + default: 'true' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + package_name: + description: "PyPI package name — used to build the PyPI link in notifications" + required: false + default: '' + label: + description: > + Display name for release notifications (e.g. the package name). When set, it + becomes the notification subject instead of the repository — useful when a repo + publishes multiple packages. Defaults to the repository. + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':package:' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/py/setup') %> + +<%= render_step('lang/py/build') %> + +<%= render_step('release/lang/py/pypi-publish') %> + +<%= render_step('release/create-github-release') %> + +<%= render_step('release/lang/py/notify-published') %> + +<%= render_step('release/lang/py/on-failure', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/validate.yml.erb b/templates/actions/release/lang/py/validate.yml.erb new file mode 100644 index 0000000..a1e24ae --- /dev/null +++ b/templates/actions/release/lang/py/validate.yml.erb @@ -0,0 +1,91 @@ +name: Validate Python Release +description: > + Checks out the release SHA, sets up uv and Python, reads the package + version, validates the release metadata (tag existence, branch, commit + metadata) and that the version is not already on PyPI, then builds to confirm + the package is publishable before the approval gate. + +inputs: + sha: + description: "Commit SHA being released" + required: true + dry_run: + description: "Skip tag existence failure in dry runs" + required: false + default: 'false' + version: + description: > + Explicit version override. If provided, the version is not read from + version_file. Use when the version is computed externally (e.g. bt-publishing-test + auto-bump, or an ephemeral prerelease). + required: false + default: '' + version_file: + description: > + Path (repo-root-relative) to a Python module exposing VERSION (e.g. + py/pkg/version.py). Read when version is not overridden. + required: false + default: '' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + python_version: + description: > + Python version (e.g. 3.13), or a path to a version file + (.python-version, .tool-versions) relative to the repo root. + required: true + package_name: + description: > + PyPI package name (matches the publish action's input). When set, validate + fails fast if this version is already published (covers prereleases, which + aren't git-tagged). Leave empty to skip. + required: false + default: '' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + required: false + default: 'py-{version}' + build_command: + description: "Override build command. When empty, `uv build` is used." + required: false + default: '' +outputs: + release_tag: + description: "The release tag (e.g. py-0.3.2)" + value: ${{ steps.validate.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.validate.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.validate.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an expected release branch" + value: ${{ steps.validate.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.validate.outputs.commit_message }} + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/py/setup') %> + +<%= render_step('lang/py/read-version') %> + +<%= render_step('release/lang/py/pypi-availability') %> + +<%= render_step('release/validate', version: "${{ steps.read-version.outputs.version }}", tag_format: "${{ inputs.tag_format }}") %> + +<%= render_step('lang/py/build') %> + +<%= render_step('lang/py/env-dump', if: "${{ failure() }}") %> diff --git a/templates/steps/lang/py/build.yml.erb b/templates/steps/lang/py/build.yml.erb new file mode 100644 index 0000000..e367a26 --- /dev/null +++ b/templates/steps/lang/py/build.yml.erb @@ -0,0 +1,26 @@ +<%# + Builds the sdist + wheel into /dist and validates the + artifacts' metadata with twine. Composed into both validate (to confirm the + package builds before the approval gate) and publish (to produce the artifacts + uploaded to PyPI). `uv build` is backend-agnostic — it honours the package's + [build-system] (setuptools/hatchling/etc.); twine runs ephemerally via uvx, so + neither is a project dependency. Set build_command to override `uv build` for a + bespoke build; it MUST still emit the sdist+wheel into /dist. + + @param build_command [String] expr for an override build command. Default: ${{ inputs.build_command }} + @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} +-%> +- name: Build distribution + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + BUILD_COMMAND: <%= locals.fetch(:build_command) { "${{ inputs.build_command }}" } %> + run: | + rm -rf dist + if [ -n "$BUILD_COMMAND" ]; then + echo "Running custom build: $BUILD_COMMAND" + eval "$BUILD_COMMAND" + else + uv build + fi + uvx twine check dist/* diff --git a/templates/steps/lang/py/env-dump.yml.erb b/templates/steps/lang/py/env-dump.yml.erb new file mode 100644 index 0000000..52d9652 --- /dev/null +++ b/templates/steps/lang/py/env-dump.yml.erb @@ -0,0 +1,20 @@ +<%# + Prints environment diagnostics (uv/Python versions, the resolved interpreter, + built artifacts, the pyproject header) to help debug a failed release. Run from + the package root. + + @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} +-%> +- name: Dump Python environment + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml ===" + head -40 pyproject.toml 2>/dev/null || echo "(no pyproject.toml)" diff --git a/templates/steps/lang/py/read-version.yml.erb b/templates/steps/lang/py/read-version.yml.erb new file mode 100644 index 0000000..18c9b87 --- /dev/null +++ b/templates/steps/lang/py/read-version.yml.erb @@ -0,0 +1,24 @@ +<%# + Resolves the package version into a `version` output. Uses the explicit + override when given, otherwise reads the VERSION attribute from the version file + (a .py module holding `VERSION = "..."`). Sets id: read-version. Runs at the repo + root, so version_file is repo-root-relative (e.g. py/autoevals/version.py). + + @param version [String] expr for an explicit version override. Default: ${{ inputs.version }} + @param version_file [String] expr for the version file path. Default: ${{ inputs.version_file }} +-%> +- name: Read version + id: read-version + shell: bash + env: + VERSION_OVERRIDE: <%= locals.fetch(:version) { "${{ inputs.version }}" } %> + VERSION_FILE: <%= locals.fetch(:version_file) { "${{ inputs.version_file }}" } %> + run: | + if [ -n "$VERSION_OVERRIDE" ]; then + echo "version=$VERSION_OVERRIDE" >> "$GITHUB_OUTPUT" + else + # exec the version file in an isolated namespace (no package import needed), + # matching how the SDKs expose VERSION in a standalone version.py. + VERSION=$(uv run --no-project python -c "import runpy,sys; print(runpy.run_path(sys.argv[1])['VERSION'])" "$VERSION_FILE") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + fi diff --git a/templates/steps/lang/py/setup.yml.erb b/templates/steps/lang/py/setup.yml.erb new file mode 100644 index 0000000..be3fdd7 --- /dev/null +++ b/templates/steps/lang/py/setup.yml.erb @@ -0,0 +1,34 @@ +<%# + Installs uv and the Python interpreter for the package. uv owns the whole + toolchain (interpreter + build + publish helpers), so no separate setup-python + is needed. The resolved version is exported as UV_PYTHON for the rest of the job, + so later steps (build, read-version) use the same interpreter. + + @param python_version [String] expr for the Python version (e.g. 3.13) OR a path to a + version file (.python-version / .tool-versions). Default: ${{ inputs.python_version }} + @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} +-%> +- name: Resolve Python version source + id: py-src + shell: bash + env: + PYTHON_VERSION: <%= locals.fetch(:python_version) { "${{ inputs.python_version }}" } %> + run: | + # python_version is either an explicit version (e.g. 3.13) or a path to a + # version file (.python-version / .tool-versions). Route by file existence. + if [ -n "$PYTHON_VERSION" ] && [ -f "$PYTHON_VERSION" ]; then + case "$PYTHON_VERSION" in + *.tool-versions) SPEC=$(awk '/^python /{print $2; exit}' "$PYTHON_VERSION") ;; + *) SPEC=$(head -1 "$PYTHON_VERSION" | tr -d '[:space:]') ;; + esac + else + SPEC="$PYTHON_VERSION" + fi + echo "spec=$SPEC" >> "$GITHUB_OUTPUT" + +- name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + # Installs this Python and exports UV_PYTHON for the job, so subsequent uv + # commands (build, read-version) use it. + python-version: ${{ steps.py-src.outputs.spec }} diff --git a/templates/steps/release/lang/py/notify-published.yml.erb b/templates/steps/release/lang/py/notify-published.yml.erb new file mode 100644 index 0000000..7d7d115 --- /dev/null +++ b/templates/steps/release/lang/py/notify-published.yml.erb @@ -0,0 +1,21 @@ +<%# + Builds the PyPI links JSON, then posts the shared release completion + notification (release/notify-published) with those links. The version comes in + explicitly (inputs.version) rather than being parsed off the tag, since the tag + format is configurable (e.g. py-{version}). +-%> +- name: Build PyPI links + id: links + shell: bash + env: + PACKAGE_NAME: ${{ inputs.package_name }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + LINKS='[]' + if [ "$DRY_RUN" != "true" ] && [ -n "$PACKAGE_NAME" ] && [ -n "$VERSION" ]; then + LINKS="[{\"label\":\"PyPI\",\"url\":\"https://pypi.org/project/$PACKAGE_NAME/$VERSION/\"}]" + fi + echo "links=$LINKS" >> "$GITHUB_OUTPUT" + +<%= render_step('release/notify-published', indent: 0, links: "${{ steps.links.outputs.links }}") %> diff --git a/templates/steps/release/lang/py/on-failure.yml.erb b/templates/steps/release/lang/py/on-failure.yml.erb new file mode 100644 index 0000000..dfcad8c --- /dev/null +++ b/templates/steps/release/lang/py/on-failure.yml.erb @@ -0,0 +1,9 @@ +<%# + The publish failure handler — dumps the Python environment for diagnostics + and posts a Slack failure notification. Composed as the terminal step of + py/publish under `if: failure()`; the composing action stamps that guard onto + each step here, and slack/send self-guards when Slack isn't set up. +-%> +<%= render_step('lang/py/env-dump', indent: 0) %> + +<%= render_step('release/notify-failure', indent: 0, failed_step: "publish", mention: "${{ inputs.failure_slack_mention }}") %> diff --git a/templates/steps/release/lang/py/pypi-availability.yml.erb b/templates/steps/release/lang/py/pypi-availability.yml.erb new file mode 100644 index 0000000..6553ad9 --- /dev/null +++ b/templates/steps/release/lang/py/pypi-availability.yml.erb @@ -0,0 +1,35 @@ +<%# + Fails fast if the package version is already published to PyPI. Read-only + registry query (no auth). Self-guards: skips when package_name is empty. + Complements release/validate's git-tag check by covering prereleases, which + aren't tagged. Assumes the read-version step (id: read-version) has run. + + @param package_name [String] expr for the PyPI package name. Default: ${{ inputs.package_name }} +-%> +- name: Check PyPI version availability + shell: bash + env: + PACKAGE_NAME: <%= locals.fetch(:package_name) { "${{ inputs.package_name }}" } %> + VERSION: ${{ steps.read-version.outputs.version }} + run: | + if [ -z "$PACKAGE_NAME" ]; then + echo "No package_name provided; skipping PyPI availability check." + exit 0 + fi + CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + --retry 3 --retry-connrefused --max-time 30 \ + "https://pypi.org/pypi/$PACKAGE_NAME/$VERSION/json") + case "$CODE" in + 200) + echo "Error: $PACKAGE_NAME $VERSION already exists on PyPI — has the version been bumped?" + exit 1 + ;; + 404) + echo "$PACKAGE_NAME $VERSION is available on PyPI." + ;; + *) + echo "Error: unexpected response from PyPI (HTTP $CODE) checking $PACKAGE_NAME $VERSION;" + echo "cannot confirm the version is unpublished — aborting rather than risk a bad release." + exit 1 + ;; + esac diff --git a/templates/steps/release/lang/py/pypi-publish.yml.erb b/templates/steps/release/lang/py/pypi-publish.yml.erb new file mode 100644 index 0000000..45a54f1 --- /dev/null +++ b/templates/steps/release/lang/py/pypi-publish.yml.erb @@ -0,0 +1,41 @@ +<%# + Publishes the built sdist + wheel to PyPI with `uv publish` — OIDC Trusted + Publishing (no token). When attestations are enabled, PEP 740 attestations are + generated with `pypi-attestations` (Sigstore-signed via ambient GitHub OIDC) so + `uv publish` uploads them alongside the dists (uv only uploads attestations, it + does not generate them). On a dry run, skips both (build + twine check already + validated the artifacts). Self-guards on the `pypi` toggle and `dry_run` in + shell, so the composing action needs no `if:`. + + Shell-based on purpose: `uv publish` (not the pypa Docker action) so the publish + composes cleanly inside this composite action — a Docker container action nested + in a composite fails image resolution. Trusted Publishing + attestations need + `id-token: write`. Assumes the build step produced /dist. +-%> +- name: Publish to PyPI + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PYPI_ENABLED: ${{ inputs.pypi }} + DRY_RUN: ${{ inputs.dry_run }} + ATTESTATIONS: ${{ inputs.attestations }} + run: | + if [ "$PYPI_ENABLED" != "true" ]; then + echo "PyPI publishing disabled; skipping." + exit 0 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: artifacts built and twine-checked; not uploaded to PyPI." + exit 0 + fi + # --trusted-publishing always: require OIDC (fail loudly rather than fall back + # to token auth). + ARGS=(--trusted-publishing always) + if [ "$ATTESTATIONS" = "true" ]; then + # uv uploads attestations but does not generate them; sign first (Sigstore, + # ambient GitHub OIDC) so the *.publish.attestation files exist for upload. + uvx pypi-attestations sign dist/*.whl dist/*.tar.gz + else + ARGS+=(--no-attestations) + fi + uv publish "${ARGS[@]}" diff --git a/test/release/py/.gitignore b/test/release/py/.gitignore new file mode 100644 index 0000000..381f8ad --- /dev/null +++ b/test/release/py/.gitignore @@ -0,0 +1,3 @@ +dist/ +*.egg-info/ +.venv/ diff --git a/test/release/py/.python-version b/test/release/py/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/test/release/py/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/test/release/py/README.md b/test/release/py/README.md new file mode 100644 index 0000000..f7193ab --- /dev/null +++ b/test/release/py/README.md @@ -0,0 +1,5 @@ +# bt-publishing-test (Python) + +Braintrust test package. Used only to validate the Python release workflow +(`.github/workflows/release-py.yml`) end-to-end against PyPI. **Not a real +package; do not use.** diff --git a/test/release/py/pyproject.toml b/test/release/py/pyproject.toml new file mode 100644 index 0000000..af35b96 --- /dev/null +++ b/test/release/py/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "bt-publishing-test" +description = "Braintrust test package. Used only to validate release workflows end-to-end. Not a real package; do not use." +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.9" +authors = [{ name = "Braintrust", email = "info@braintrust.dev" }] +dynamic = ["version"] + +[project.urls] +Repository = "https://github.com/braintrustdata/sdk-actions" + +# Mirrors the real SDKs: version is sourced from a standalone version.py module, +# read by the release action's read-version step too. +[tool.setuptools.dynamic] +version = { attr = "bt_publishing_test.version.VERSION" } + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/test/release/py/src/bt_publishing_test/__init__.py b/test/release/py/src/bt_publishing_test/__init__.py new file mode 100644 index 0000000..b558e4d --- /dev/null +++ b/test/release/py/src/bt_publishing_test/__init__.py @@ -0,0 +1,3 @@ +from .version import VERSION + +__version__ = VERSION diff --git a/test/release/py/src/bt_publishing_test/version.py b/test/release/py/src/bt_publishing_test/version.py new file mode 100644 index 0000000..775ee2c --- /dev/null +++ b/test/release/py/src/bt_publishing_test/version.py @@ -0,0 +1 @@ +VERSION = "0.0.1" # Placeholder; bumped at runtime by the release workflow.