Python: Release #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: "Python: Release" | |
| # Manual release of `strands-agents` to PyPI. | |
| # Setup, recovery, fork-testing: .github/workflows/RELEASE.md | |
| # | |
| # Shape: scan → lint/unit/integ/audit/build → inspect → notes → approve → | |
| # tag + release → publish. | |
| # | |
| # Publish is the last step. Everything before it runs on a fork too — only | |
| # the OIDC upload fails there, because the fork is not the registered | |
| # trusted publisher. See RELEASE.md → "Fork testability". | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Explicit version, e.g. 1.4.0 (no leading v, no prefix).' | |
| required: true | |
| type: string | |
| sha: | |
| description: 'Optional commit SHA to release (must be an ancestor of origin/main). Defaults to current origin/main.' | |
| required: false | |
| default: '' | |
| type: string | |
| dry_run: | |
| description: 'Skip approval + tag + publish. Build/inspect/notes still run so you can review the artifact on the run page.' | |
| required: true | |
| default: true | |
| type: boolean | |
| run_integ_tests: | |
| description: 'Run integration tests. No effect on upstream (integ always runs there). On a fork: false skips integ; true runs integ but requires the fork to have its own AWS credentials configured.' | |
| required: true | |
| default: false | |
| type: boolean | |
| concurrency: | |
| group: release-python | |
| cancel-in-progress: false | |
| jobs: | |
| scan-commits: | |
| name: Resolve SHA and validate version | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| release_sha: ${{ steps.scan.outputs.release_sha }} | |
| prev_tag: ${{ steps.scan.outputs.prev_tag }} | |
| new_tag: ${{ steps.scan.outputs.new_tag }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| persist-credentials: false | |
| - name: Resolve SHA, find previous tag, validate version | |
| id: scan | |
| env: | |
| NEW_VERSION: ${{ inputs.version }} | |
| SHA_INPUT: ${{ inputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| # 1. Validate the typed version (bare semver, no prefix). | |
| if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "::error::version '$NEW_VERSION' is not bare semver (expected MAJOR.MINOR.PATCH, no 'v')." | |
| exit 1 | |
| fi | |
| # 2. Resolve the SHA. Default to origin/main; if a sha was typed, | |
| # accept it only if it's an ancestor of origin/main. | |
| if [ -n "$SHA_INPUT" ]; then | |
| RELEASE_SHA=$(git rev-parse --verify "$SHA_INPUT^{commit}" 2>/dev/null) || { | |
| echo "::error::sha '$SHA_INPUT' is not a valid commit." | |
| exit 1 | |
| } | |
| if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then | |
| echo "::error::sha $RELEASE_SHA is not an ancestor of origin/main — release only from main history." | |
| exit 1 | |
| fi | |
| else | |
| RELEASE_SHA=$(git rev-parse origin/main) | |
| fi | |
| # 3. Pick tag prefix and resolve previous tag. | |
| TAG_PREFIX="python/v" | |
| PREV_TAG=$(git tag --list "${TAG_PREFIX}*" --sort=-v:refname | head -n1) | |
| if [ -z "$PREV_TAG" ]; then | |
| echo "::error::No prior tag matching ${TAG_PREFIX}* — refusing to release without a baseline. (Fork-testing? Run \`git fetch upstream --tags && git push origin --tags\` first.)" | |
| exit 1 | |
| fi | |
| PREV_VERSION="${PREV_TAG#"${TAG_PREFIX}"}" | |
| NEW_TAG="${TAG_PREFIX}${NEW_VERSION}" | |
| # 4. Reject duplicate / non-monotonic / pre-existing tags. | |
| if [ "$NEW_VERSION" = "$PREV_VERSION" ]; then | |
| echo "::error::version $NEW_VERSION matches existing tag $PREV_TAG." | |
| exit 1 | |
| fi | |
| HIGHER=$(printf '%s\n%s\n' "$PREV_VERSION" "$NEW_VERSION" | sort -V | tail -n1) | |
| if [ "$HIGHER" != "$NEW_VERSION" ]; then | |
| echo "::error::version $NEW_VERSION is not greater than previous $PREV_VERSION." | |
| exit 1 | |
| fi | |
| if git rev-parse --verify "refs/tags/$NEW_TAG" >/dev/null 2>&1; then | |
| echo "::error::tag $NEW_TAG already exists." | |
| exit 1 | |
| fi | |
| # 5. Sanity-check: at least one commit since prev tag. | |
| COUNT=$(git rev-list --count "$PREV_TAG..$RELEASE_SHA") | |
| if [ "$COUNT" = "0" ]; then | |
| echo "::error::no commits between $PREV_TAG and $RELEASE_SHA — nothing to release." | |
| exit 1 | |
| fi | |
| { | |
| echo "release_sha=$RELEASE_SHA" | |
| echo "prev_tag=$PREV_TAG" | |
| echo "new_tag=$NEW_TAG" | |
| } >> "$GITHUB_OUTPUT" | |
| { | |
| echo "### Release scan" | |
| echo "" | |
| echo "| | |" | |
| echo "|---|---|" | |
| echo "| Previous tag | \`$PREV_TAG\` (v$PREV_VERSION) |" | |
| echo "| Proposed tag | \`$NEW_TAG\` (v$NEW_VERSION) |" | |
| echo "| Pinned SHA | \`$RELEASE_SHA\` |" | |
| echo "| Commits since prev tag | **$COUNT** |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # ── Lint + unit-test gates ───────────────────────────────────────────── | |
| test-lint: | |
| name: Python lint + unit tests | |
| needs: scan-commits | |
| uses: ./.github/workflows/python-test-lint.yml | |
| permissions: | |
| contents: read | |
| with: | |
| ref: ${{ needs.scan-commits.outputs.release_sha }} | |
| secrets: | |
| CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | |
| # ── Integration tests ────────────────────────────────────────────────── | |
| # Always runs on upstream. On a fork: opt-in via `run_integ_tests: true` | |
| # (fork needs its own AWS credentials). | |
| integ: | |
| name: Python integration tests | |
| needs: scan-commits | |
| if: github.event.repository.fork != true || inputs.run_integ_tests == true | |
| uses: ./.github/workflows/python-integration-test.yml | |
| permissions: | |
| id-token: write | |
| contents: read | |
| pull-requests: read | |
| secrets: inherit | |
| with: | |
| ref: ${{ needs.scan-commits.outputs.release_sha }} | |
| # ── Security audit (informational) ───────────────────────────────────── | |
| security-audit: | |
| name: Python security audit | |
| needs: scan-commits | |
| uses: ./.github/workflows/python-security-audit.yml | |
| permissions: | |
| contents: read | |
| with: | |
| ref: ${{ needs.scan-commits.outputs.release_sha }} | |
| # ── Build + smoke test (uploads pypi-build-output artifact) ──────────── | |
| package-build: | |
| name: Python wheel install smoke test | |
| needs: scan-commits | |
| uses: ./.github/workflows/python-test-package-build.yml | |
| permissions: | |
| contents: read | |
| with: | |
| ref: ${{ needs.scan-commits.outputs.release_sha }} | |
| version: ${{ inputs.version }} | |
| # ── Inspect the built artifact ───────────────────────────────────────── | |
| # Downloads pypi-build-output, checks metadata and version, re-uploads | |
| # as pypi-dist-bundle. publish-pypi pulls from the same bundle, so the | |
| # bytes on the run page are the bytes uploaded to PyPI. | |
| inspect: | |
| name: Inspect Python dists | |
| needs: package-build | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Download build output | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: pypi-build-output | |
| path: dist | |
| - uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.10' | |
| - name: List wheel + sdist contents | |
| run: | | |
| set -euo pipefail | |
| for f in dist/*.whl; do | |
| echo "::group::$f"; python -m zipfile -l "$f"; echo "::endgroup::" | |
| done | |
| for f in dist/*.tar.gz; do | |
| echo "::group::$f"; tar tzf "$f"; echo "::endgroup::" | |
| done | |
| - name: Assert artifacts are stamped with the typed version | |
| # Wheel filenames encode the version (PEP 427) and publish cannot | |
| # change them. The build job already checks `strands.__version__` | |
| # at install time — this catches the filename side. | |
| env: | |
| EXPECTED: ${{ inputs.version }} | |
| run: | | |
| python - <<'PYEOF' | |
| import os | |
| import re | |
| import sys | |
| from pathlib import Path | |
| expected = os.environ["EXPECTED"] | |
| errors = [] | |
| checked = 0 | |
| for f in sorted(Path("dist").iterdir()): | |
| name = f.name | |
| if name.endswith(".whl"): | |
| m = re.match(r"^[^-]+-([^-]+)-", name) | |
| elif name.endswith(".tar.gz"): | |
| m = re.match(r"^[^-]+-(.+)\.tar\.gz$", name) | |
| else: | |
| continue | |
| checked += 1 | |
| version = m.group(1) if m else None | |
| if version != expected: | |
| errors.append(f"{name}: version is {version!r}, expected {expected!r}") | |
| if not checked: | |
| print("::error::No wheel or sdist found in dist/", file=sys.stderr) | |
| sys.exit(1) | |
| if errors: | |
| for e in errors: | |
| print(f"::error::{e}") | |
| sys.exit(1) | |
| print(f"All {checked} artifact(s) stamped with version {expected}") | |
| PYEOF | |
| - name: twine check (metadata + README rendering) | |
| run: | | |
| pip install --no-cache-dir twine | |
| twine check dist/* | |
| - name: Upload verified bundle for publish | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: pypi-dist-bundle | |
| path: dist/* | |
| if-no-files-found: error | |
| retention-days: 30 | |
| # ── Draft notes grouped by commit type ───────────────────────────────── | |
| # Polished release notes live on the website. This step just gives | |
| # reviewers a quick "what landed since prev tag" view. | |
| draft-notes: | |
| name: Draft release notes (grouped by commit type) | |
| needs: | |
| - scan-commits | |
| - test-lint | |
| - integ | |
| - security-audit | |
| - package-build | |
| - inspect | |
| if: | | |
| always() && | |
| needs.scan-commits.result == 'success' && | |
| needs.test-lint.result == 'success' && | |
| (needs.integ.result == 'success' || needs.integ.result == 'skipped') && | |
| needs.security-audit.result == 'success' && | |
| needs.package-build.result == 'success' && | |
| needs.inspect.result == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.scan-commits.outputs.release_sha }} | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| persist-credentials: false | |
| - name: Render notes | |
| env: | |
| PREV_TAG: ${{ needs.scan-commits.outputs.prev_tag }} | |
| NEW_REF: ${{ needs.scan-commits.outputs.release_sha }} | |
| NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }} | |
| run: | | |
| set -euo pipefail | |
| # Feed commits to the grouper as `hash<TAB>subject` lines and let | |
| # it bucket them by conventional-commit type (feat, fix, ...). | |
| git log --no-merges --pretty=format:'%h%x09%s' "$PREV_TAG..$NEW_REF" \ | |
| | NEW_TAG="$NEW_TAG" PREV_TAG="$PREV_TAG" \ | |
| python .github/scripts/group-release-notes.py > release-notes.md | |
| - name: Render release summary | |
| env: | |
| NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }} | |
| RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| IS_FORK: ${{ github.event.repository.fork }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "## Release proposal" | |
| echo "" | |
| echo "| | |" | |
| echo "|---|---|" | |
| echo "| Package | \`strands-agents\` (Python) |" | |
| echo "| Proposed tag | \`$NEW_TAG\` |" | |
| echo "| Pinned SHA | \`$RELEASE_SHA\` |" | |
| echo "| Dry run | \`$DRY_RUN\` |" | |
| echo "| Running on fork | \`$IS_FORK\` |" | |
| echo "" | |
| echo "> Reviewers: the verified artifact is uploaded as \`pypi-dist-bundle\` on this run's page. Download and install in a fresh venv to sanity-check before approving." | |
| echo "" | |
| echo "### Drafted notes" | |
| echo "" | |
| cat release-notes.md | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload release notes artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: release-notes | |
| path: release-notes.md | |
| retention-days: 30 | |
| # ── Reviewer approval (skipped on dry runs) ──────────────────────────── | |
| approve-release: | |
| name: Wait for reviewer approvals | |
| needs: [scan-commits, draft-notes] | |
| if: inputs.dry_run != true | |
| runs-on: ubuntu-latest | |
| environment: | |
| # Shared with release-typescript.yml. Configure reviewers + main-only | |
| # deployment branches in repo settings. | |
| # | |
| # On forks the environment has no reviewer config, so this just | |
| # passes — the publish step is the one that fails there. | |
| name: release-gate | |
| permissions: {} | |
| steps: | |
| - name: Acknowledge approval | |
| env: | |
| NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }} | |
| RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }} | |
| run: | | |
| echo "Approved to release $NEW_TAG at $RELEASE_SHA." | |
| # ── Tag + GitHub release ─────────────────────────────────────────────── | |
| # Runs before publish so the tag is the source of truth. If publish | |
| # fails, the tag stays — just re-run publish. Runs on forks too; creates | |
| # the release on the fork's repo so fork-tests go end-to-end. | |
| create-gh-release: | |
| name: Create GitHub release | |
| needs: [scan-commits, draft-notes, approve-release] | |
| if: inputs.dry_run != true | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.scan-commits.outputs.release_sha }} | |
| persist-credentials: false | |
| - name: Download release notes artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: release-notes | |
| - name: Create release (atomically tags the pinned SHA) | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }} | |
| RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }} | |
| run: | | |
| set -euo pipefail | |
| gh release create "$NEW_TAG" \ | |
| --target "$RELEASE_SHA" \ | |
| --title "$NEW_TAG" \ | |
| --notes-file release-notes.md | |
| # ── Publish to PyPI ──────────────────────────────────────────────────── | |
| # Last step. On a fork this fails at the OIDC step — by design. | |
| publish-pypi: | |
| name: Publish to PyPI | |
| needs: [inspect, approve-release, create-gh-release] | |
| if: inputs.dry_run != true | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: pypi | |
| url: https://pypi.org/p/strands-agents | |
| permissions: | |
| id-token: write | |
| contents: read | |
| steps: | |
| - name: Download verified dists | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: pypi-dist-bundle | |
| path: dist | |
| - name: Publish to PyPI | |
| uses: pypa/gh-action-pypi-publish@release/v1 |