From a5db44a6110eaa26a74fd2bc54ab88d0c299e68e Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Fri, 11 Sep 2026 16:08:01 +0200 Subject: [PATCH 1/6] ci: replace the PR-title bump script with release-please The bump script derived the version from the merged PR title, committed pyproject.toml locally so the tag pointed at a correct tree, and pushed the tag without ever updating the default branch. pyproject.toml on main therefore drifted to 4.1.0 while PyPI is at 6.1.0, and uv.lock carries the same stale self-version. release-please keeps the bump in a reviewable Release PR instead, so the version files on main match the release. Config uses the python strategy with a TOML extra-file for the uv.lock self-entry, and include-component-in-tag false to keep the vX.Y.Z tag shape. bootstrap-sha is the main commit v6.1.0 was released from: the tag itself sits on the bump commit the old workflow created off-branch, so release-please cannot find it by walking main. Publishing chains inside the release run rather than triggering on the release event, because a GitHub Release created with GITHUB_TOKEN starts no new workflow. The workflow file name and the pypi environment are unchanged, which is what PyPI Trusted Publishing binds to. --- .github/workflows/pr_title.yml | 17 +++ .github/workflows/release.yml | 210 ++++++-------------------------- .release-please-manifest.json | 3 + CHANGELOG.md | 2 - README.md | 17 +-- release-please-config.json | 17 +++ scripts/release/bump_version.py | 197 ------------------------------ 7 files changed, 78 insertions(+), 385 deletions(-) create mode 100644 .github/workflows/pr_title.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json delete mode 100755 scripts/release/bump_version.py diff --git a/.github/workflows/pr_title.yml b/.github/workflows/pr_title.yml new file mode 100644 index 00000000..923683d8 --- /dev/null +++ b/.github/workflows/pr_title.yml @@ -0,0 +1,17 @@ +name: Lint PR title + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: read + +jobs: + pr_title: + name: 👮 Conventional PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d17339b..db3fe658 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,112 +1,45 @@ name: Release on: - workflow_dispatch: - inputs: - version_bump: - description: 'Version bump type for manual release' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - use_current_version: - description: 'Skip version bump and publish the version already set in pyproject.toml' - required: false - default: false - type: boolean - pull_request: - types: [closed] + push: branches: - main + - '*.x' + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write concurrency: - group: release-${{ github.event.pull_request.base.ref || github.ref_name }} + group: release-${{ github.ref_name }} cancel-in-progress: false -permissions: - contents: read - jobs: - prepare: - name: Prepare release - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true + release: + name: 🚀 Release runs-on: ubuntu-latest outputs: - should_release: ${{ steps.release_meta_final.outputs.should_release }} - bump: ${{ steps.release_meta_final.outputs.bump }} - previous_version: ${{ steps.release_meta_final.outputs.previous_version }} - version: ${{ steps.release_meta_final.outputs.version }} - tag: ${{ steps.release_meta_final.outputs.tag }} + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} steps: - - uses: actions/checkout@v5 + # Keeps the Release PR current; when a merged Release PR is pending, creates the tag + # and the GitHub Release at its merge commit. + - uses: googleapis/release-please-action@v4 + id: release with: - fetch-depth: 0 - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} - - - name: Skip when PR is already released - id: already_released - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "value=false" >> "$GITHUB_OUTPUT" - else - # Idempotency is tracked on the release tag (annotated with the PR number - # in "Create release tag"), not on a default-branch commit: the version - # bump is no longer pushed to the protected default branch. - if git for-each-ref refs/tags --format='%(contents)' | grep -q "(pr #${{ github.event.pull_request.number }})"; then - echo "value=true" >> "$GITHUB_OUTPUT" - else - echo "value=false" >> "$GITHUB_OUTPUT" - fi - fi - - - name: Determine version bump (from PR metadata) - id: release_meta - if: github.event_name == 'pull_request' && steps.already_released.outputs.value != 'true' - env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: | - python3 scripts/release/bump_version.py \ - --title "$PR_TITLE" \ - --output "$GITHUB_OUTPUT" - - - name: Determine version bump (manual) - id: release_meta_manual - if: github.event_name == 'workflow_dispatch' - run: | - python3 scripts/release/bump_version.py \ - --manual-bump "${{ github.event.inputs.version_bump }}" \ - --use-current-version "${{ github.event.inputs.use_current_version }}" \ - --output "$GITHUB_OUTPUT" - - - name: Consolidate release metadata - id: release_meta_final - run: | - if [ "${{ steps.already_released.outputs.value }}" = "true" ]; then - echo "should_release=false" >> "$GITHUB_OUTPUT" - echo "bump=none" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "should_release=${{ steps.release_meta_manual.outputs.should_release }}" >> "$GITHUB_OUTPUT" - echo "bump=${{ steps.release_meta_manual.outputs.bump }}" >> "$GITHUB_OUTPUT" - echo "previous_version=${{ steps.release_meta_manual.outputs.previous_version }}" >> "$GITHUB_OUTPUT" - echo "version=${{ steps.release_meta_manual.outputs.version }}" >> "$GITHUB_OUTPUT" - echo "tag=${{ steps.release_meta_manual.outputs.tag }}" >> "$GITHUB_OUTPUT" - else - echo "should_release=${{ steps.release_meta.outputs.should_release }}" >> "$GITHUB_OUTPUT" - echo "bump=${{ steps.release_meta.outputs.bump }}" >> "$GITHUB_OUTPUT" - echo "previous_version=${{ steps.release_meta.outputs.previous_version }}" >> "$GITHUB_OUTPUT" - echo "version=${{ steps.release_meta.outputs.version }}" >> "$GITHUB_OUTPUT" - echo "tag=${{ steps.release_meta.outputs.tag }}" >> "$GITHUB_OUTPUT" - fi + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + target-branch: ${{ github.ref_name }} + # The GitHub Release above is created with GITHUB_TOKEN, which never starts another + # workflow run, so publishing chains here instead of on a release event. test-unit: name: Test (unit) - needs: prepare - if: needs.prepare.outputs.should_release == 'true' + needs: release + if: needs.release.outputs.release_created == 'true' uses: ./.github/workflows/run_tests.yml with: marker: 'not integration' @@ -114,110 +47,39 @@ jobs: test-integration: name: Test (integration) - needs: prepare - if: needs.prepare.outputs.should_release == 'true' + needs: release + if: needs.release.outputs.release_created == 'true' uses: ./.github/workflows/run_tests.yml with: marker: 'integration' secrets: inherit - release: - name: 🚀 Release - needs: [prepare, test-unit, test-integration] - if: needs.prepare.outputs.should_release == 'true' + publish: + name: 📦 Publish to PyPI + needs: [release, test-unit, test-integration] + if: needs.release.outputs.release_created == 'true' runs-on: ubuntu-latest + # PyPI Trusted Publishing is bound to this workflow file name and this environment. environment: pypi permissions: - contents: write + contents: read id-token: write steps: - uses: actions/checkout@v5 with: - fetch-depth: 0 - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} + ref: ${{ needs.release.outputs.tag_name }} - uses: ./.github/actions/python-uv-setup - - name: Apply version bump - env: - VERSION: ${{ needs.prepare.outputs.version }} - run: | - python3 - <<'PY' - import os, re - from pathlib import Path - version = os.environ['VERSION'] - path = Path('pyproject.toml') - text = path.read_text(encoding='utf-8') - pattern = re.compile(r'^version\s*=\s*"\d+\.\d+\.\d+"\s*$', re.MULTILINE) - new_text, count = pattern.subn(f'version = "{version}"', text, count=1) - if count == 0: - raise SystemExit('Could not update version line in pyproject.toml') - path.write_text(new_text, encoding='utf-8') - PY - - # Commit the bump locally only, so the release tag points at a tree with the - # correct version. It is intentionally NOT pushed to the protected default - # branch (which rejects direct pushes). Versioning is driven by tags, not by - # pyproject.toml on the default branch (see bump_version.py: find_latest_semver_tag). - - name: Commit version bump (local, for tagging only) - env: - VERSION: ${{ needs.prepare.outputs.version }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add pyproject.toml - if git diff --cached --quiet; then - echo "No version changes to commit." - else - git commit -m "chore(release): v${VERSION}" - fi - - - name: Create release tag - env: - TAG: ${{ needs.prepare.outputs.tag }} - PR: ${{ github.event.pull_request.number }} - run: | - if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then - echo "Tag ${TAG} already exists; skipping tag creation." - exit 0 - fi - # Annotate the tag with the trigger; the PR number is used by the - # "Skip when PR is already released" idempotency check. - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - git tag -a "${TAG}" -m "chore(release): ${TAG} (manual)" - else - git tag -a "${TAG}" -m "chore(release): ${TAG} (pr #${PR})" - fi - git push origin "${TAG}" - - - name: Clean dist directory - run: rm -rf dist - - name: Build distributions - run: uv build + run: rm -rf dist && uv build - name: Publish to PyPI (Trusted Publishing) run: uv publish - - name: Create release on GitHub - uses: ncipollo/release-action@v1 - with: - tag: ${{ needs.prepare.outputs.tag }} - token: ${{ secrets.GITHUB_TOKEN }} - skipIfReleaseExists: true - body: | - Release v${{ needs.prepare.outputs.version }} - - - Bump type: `${{ needs.prepare.outputs.bump }}` - - Previous: `${{ needs.prepare.outputs.previous_version }}` - - Next: `${{ needs.prepare.outputs.version }}` - - Trigger: `${{ github.event_name }}` - - Install with: `pip install getstream==${{ needs.prepare.outputs.version }}` - - name: Verify pip install env: UV_NO_SOURCES: "1" run: | - uv pip install "getstream==${{ needs.prepare.outputs.version }}" || \ + uv pip install "getstream==${{ needs.release.outputs.version }}" || \ echo "WARNING: pip install verification failed (PyPI index may need a moment to propagate)" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..8ace9152 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "6.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 72cace50..2ccf3b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - ## [4.2.0] - 2026-07-24 ### Added diff --git a/README.md b/README.md index cd95dee2..32ac2838 100644 --- a/README.md +++ b/README.md @@ -257,20 +257,13 @@ uv run pytest tests/test_video.py::test_specific_function -v ## Releases -Releases use two paths: +Releases are driven by [release-please](https://github.com/googleapis/release-please). -- **Default**: automatic release when a PR is merged to `main`. The PR title (and body) drives the semver bump. -- **Fallback**: manual release via the `Release` workflow's `workflow_dispatch` (admin use). Select a `version_bump` (`patch`/`minor`/`major`). `use_current_version=true` skips the bump and publishes whatever is already in `pyproject.toml`. +- Merge PRs to `main` with conventional-commit titles. The PR title becomes the squash commit subject and decides the next version: `feat:` is a minor, `fix:` and `perf:` are a patch, `feat!:` or `(scope)!:` is a major. Other types (`chore`, `ci`, `docs`, `test`, `refactor`) ship nothing. +- release-please keeps a Release PR open with the version bump in `pyproject.toml`, `uv.lock` and `CHANGELOG.md`. It is opened by `github-actions[bot]`, so approve it and run its held checks like any other PR. +- Merging the Release PR creates the tag and the GitHub Release, runs lint, type-check and the unit and integration matrix on the tagged commit, then publishes to PyPI via Trusted Publishing (OIDC). A failed publish is retried with "Re-run failed jobs" on that workflow run. -Automatic semver bump rules: - -- `feat:` -> minor -- `fix:` (or `bug:`) -> patch -- `feat!:`, `(scope)!:`, or `BREAKING CHANGE` in the PR body/title -> major - -PRs with any other prefix do not trigger a release. - -The release pipeline runs lint, type-check, and the full test matrix (unit + integration, across all supported Python versions) on the merged commit before publishing to PyPI via Trusted Publishing (OIDC). Each step in the publish job is idempotent: a failed run can be re-dispatched from the Actions UI. +To force a specific version, type `Release-As: X.Y.Z` in the commit message box of the squash dialog when merging a PR; the PR description is not copied there. To hotfix while `main` carries unreleased work, branch `N.x` from the last tag, cherry-pick the fix, and merge the Release PR that release-please opens against that branch. ## License diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..78421d99 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "last-release-sha": "242d57c68423e13e55a9ae7c3447622127541ab0", + "packages": { + ".": { + "release-type": "python", + "include-component-in-tag": false, + "extra-files": [ + { + "type": "toml", + "path": "uv.lock", + "jsonpath": "$.package[?(@.name.value=='getstream')].version" + } + ] + } + } +} diff --git a/scripts/release/bump_version.py b/scripts/release/bump_version.py deleted file mode 100755 index bdc0d40e..00000000 --- a/scripts/release/bump_version.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env python3 -"""Compute the next release version from PR title/body or workflow_dispatch input. - -Usage: - scripts/release/bump_version.py \ - --title "feat: add x" --body-file "$PR_BODY_FILE" \ - --output "$GITHUB_OUTPUT" - - scripts/release/bump_version.py \ - --manual-bump patch \ - --output "$GITHUB_OUTPUT" - - scripts/release/bump_version.py \ - --manual-bump patch --use-current-version true \ - --output "$GITHUB_OUTPUT" - -Outputs (written to --output or stdout) follow the convention shared with the -getstream-php / getstream-ruby / getstream-net / getstream-go / stream-sdk-java -bump scripts: - - should_release=true|false - bump=major|minor|patch|none - previous_version=X.Y.Z - version=X.Y.Z - tag=vX.Y.Z - -Side effect: in auto and manual-with-bump modes, updates the `version` field in -the top-level [project] table of pyproject.toml. -""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -from pathlib import Path - -PYPROJECT_PATH = Path("pyproject.toml") -SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") -VERSION_LINE_PATTERN = re.compile( - r'^version\s*=\s*"(\d+\.\d+\.\d+)"\s*$', - re.MULTILINE, -) - - -def run(cmd: list[str]) -> str: - result = subprocess.run(cmd, check=False, capture_output=True, text=True) - return result.stdout.strip() - - -def find_latest_semver_tag() -> str: - raw = run(["git", "tag", "--list"]) - if not raw: - return "0.0.0" - versions: list[tuple[int, int, int]] = [] - for line in raw.splitlines(): - candidate = line.strip().lstrip("v") - if SEMVER_PATTERN.match(candidate): - major, minor, patch = candidate.split(".") - versions.append((int(major), int(minor), int(patch))) - if not versions: - return "0.0.0" - versions.sort() - return "{}.{}.{}".format(*versions[-1]) - - -def determine_bump_type(title: str) -> str: - # Breaking changes are signalled only by the `!` marker in the title - # (e.g. `feat!:`). Free-text body/title prose is not trusted: a PR that - # merely mentions "BREAKING CHANGE" must not force a major bump. - title = title.strip() - match = re.match(r"^([a-zA-Z]+)(\([^)]+\))?(!)?:", title) - if not match: - return "none" - if match.group(3) == "!": - return "major" - type_ = match.group(1).lower() - if type_ == "feat": - return "minor" - if type_ in {"fix", "bug"}: - return "patch" - return "none" - - -def increment_version(current: str, bump: str) -> str: - major, minor, patch = (int(x) for x in current.split(".")) - if bump == "major": - return f"{major + 1}.0.0" - if bump == "minor": - return f"{major}.{minor + 1}.0" - if bump == "patch": - return f"{major}.{minor}.{patch + 1}" - return current - - -def read_pyproject_version(path: Path) -> str: - text = path.read_text(encoding="utf-8") - match = VERSION_LINE_PATTERN.search(text) - if not match: - raise RuntimeError( - 'Could not find a static `version = "X.Y.Z"` line in pyproject.toml' - ) - return match.group(1) - - -def update_pyproject_version(path: Path, version: str) -> None: - text = path.read_text(encoding="utf-8") - new_text, count = VERSION_LINE_PATTERN.subn(f'version = "{version}"', text, count=1) - if count == 0: - raise RuntimeError( - 'Could not update version line in pyproject.toml (expected `version = "X.Y.Z"`)' - ) - path.write_text(new_text, encoding="utf-8") - - -def parse_bool(value: str) -> bool: - return value.strip().lower() == "true" - - -def write_outputs(output_path: str, entries: dict[str, str]) -> None: - if not output_path: - for key, value in entries.items(): - print(f"{key}={value}") - return - with open(output_path, "a", encoding="utf-8") as fh: - for key, value in entries.items(): - fh.write(f"{key}={value}\n") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--title", default="") - parser.add_argument("--output", default="") - parser.add_argument("--manual-bump", dest="manual_bump", default="") - parser.add_argument( - "--use-current-version", - dest="use_current_version", - default="false", - ) - args = parser.parse_args() - - manual = args.manual_bump.strip().lower() - use_current = parse_bool(args.use_current_version) - - if manual: - if manual not in {"major", "minor", "patch"}: - print("manual-bump must be one of: major, minor, patch", file=sys.stderr) - return 1 - previous = find_latest_semver_tag() - if use_current: - next_version = read_pyproject_version(PYPROJECT_PATH) - else: - next_version = increment_version(previous, manual) - update_pyproject_version(PYPROJECT_PATH, next_version) - write_outputs( - args.output, - { - "should_release": "true", - "bump": manual, - "previous_version": previous, - "version": next_version, - "tag": f"v{next_version}", - }, - ) - return 0 - - bump = determine_bump_type(args.title) - if bump == "none": - write_outputs( - args.output, - { - "should_release": "false", - "bump": "none", - }, - ) - return 0 - - previous = find_latest_semver_tag() - next_version = increment_version(previous, bump) - update_pyproject_version(PYPROJECT_PATH, next_version) - - write_outputs( - args.output, - { - "should_release": "true", - "bump": bump, - "previous_version": previous, - "version": next_version, - "tag": f"v{next_version}", - }, - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From 4f88f41e92bc75a2c9180332bbecd4f1a6544369 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Fri, 11 Sep 2026 16:27:52 +0200 Subject: [PATCH 2/6] ci: do not persist credentials in the publish checkout uv build and uv publish execute project and dependency code, and no step after the checkout writes to the repository. --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db3fe658..b0d2b2cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,9 @@ jobs: - uses: actions/checkout@v5 with: ref: ${{ needs.release.outputs.tag_name }} + # uv build and uv publish execute project and dependency code, and nothing + # here writes to the repository, so do not leave GITHUB_TOKEN in .git/config. + persist-credentials: false - uses: ./.github/actions/python-uv-setup From 8d94e3e4a5912b79fe8fc301bb26b1b6ca12ca7b Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Fri, 11 Sep 2026 17:27:59 +0200 Subject: [PATCH 3/6] ci: gate tagging on the test suite and stop the duplicate matrix on main The tag and the GitHub Release cannot be withdrawn, so they must not be created before the suite has run. The workflow now detects a merged Release PR waiting to be tagged, runs lint, type-check and both matrices against the commit that will be tagged, and only then calls release-please's release half and publishes. run_tests.yml takes a ref so the tests and the publish build the same tree even when main has moved past the Release PR's merge commit. ci.yml no longer fires on main or *.x: release.yml runs the same reusable workflow there, and both firing on one push doubled the live legs against the Stream app several SDK repos share. Adds timeouts to the release and publish jobs, which the six-hour default left able to hold the non-cancelling release concurrency group, narrows the workflow-level permissions to contents: read with per-job grants, and restores a manual publish path: dispatching with publish_tag builds and publishes an existing tag, which is the only recovery once GitHub retires the original run. --- .github/workflows/ci.yml | 5 +- .github/workflows/release.yml | 126 ++++++++++++++++++++++++++------ .github/workflows/run_tests.yml | 13 ++++ 3 files changed, 120 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb13efe6..4686c5a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,10 @@ name: CI (unit) on: + # main and *.x are excluded: release.yml runs the same reusable workflow on those + # branches as the gate before tagging, and both firing on one push would double the + # live legs against the Stream app several SDK repos share. push: - branches: [ "**" ] + branches-ignore: [ main, "*.x" ] pull_request: branches: [ "**" ] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0d2b2cf..550dede0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,68 +6,148 @@ on: - main - '*.x' workflow_dispatch: + inputs: + publish_tag: + description: 'Existing tag to (re)publish to PyPI, e.g. v6.1.1. Leave empty for a normal release run.' + required: false + default: '' permissions: - contents: write - issues: write - pull-requests: write + contents: read concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false jobs: - release: - name: 🚀 Release + # Reversible half: keep the Release PR current. Never gated. + release-pr: + name: Release PR + if: >- + (github.event_name == 'push' || inputs.publish_tag == '') && + (github.ref_name == 'main' || endsWith(github.ref_name, '.x')) runs-on: ubuntu-latest - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - version: ${{ steps.release.outputs.version }} + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: write steps: - # Keeps the Release PR current; when a merged Release PR is pending, creates the tag - # and the GitHub Release at its merge commit. - uses: googleapis/release-please-action@v4 - id: release with: config-file: release-please-config.json manifest-file: .release-please-manifest.json target-branch: ${{ github.ref_name }} + skip-github-release: true + + # Tagging and the GitHub Release are irreversible, so the suite has to run before + # them, which means knowing a release is pending before the suite starts. The tag + # lands on the merged Release PR's merge commit, not on this branch's tip, so that + # commit is also what gets tested. + detect: + name: Detect pending release + if: >- + (github.event_name == 'push' || inputs.publish_tag == '') && + (github.ref_name == 'main' || endsWith(github.ref_name, '.x')) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + pending: ${{ steps.find.outputs.pending }} + sha: ${{ steps.find.outputs.sha }} + steps: + - name: Find a merged Release PR waiting to be tagged + id: find + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(gh api "repos/${GITHUB_REPOSITORY}/pulls?state=closed&base=${GITHUB_REF_NAME}&sort=updated&direction=desc&per_page=50" \ + --jq '[.[] | select(.merged_at != null and ([.labels[].name] | index("autorelease: pending")))] | .[0].merge_commit_sha // empty')" + if [ -z "$sha" ]; then + echo "No pending release." + echo "pending=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Pending release will be tagged at $sha." + echo "pending=true" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" - # The GitHub Release above is created with GITHUB_TOKEN, which never starts another - # workflow run, so publishing chains here instead of on a release event. test-unit: name: Test (unit) - needs: release - if: needs.release.outputs.release_created == 'true' + needs: detect + if: needs.detect.outputs.pending == 'true' uses: ./.github/workflows/run_tests.yml with: marker: 'not integration' + ref: ${{ needs.detect.outputs.sha }} secrets: inherit test-integration: name: Test (integration) - needs: release - if: needs.release.outputs.release_created == 'true' + needs: detect + if: needs.detect.outputs.pending == 'true' uses: ./.github/workflows/run_tests.yml with: marker: 'integration' + ref: ${{ needs.detect.outputs.sha }} secrets: inherit + # Irreversible half. + release: + name: 🚀 Tag and release + needs: [detect, test-unit, test-integration] + if: needs.detect.outputs.pending == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + target-branch: ${{ github.ref_name }} + skip-github-pull-request: true + + # Chained rather than triggered on the release event, because a GitHub Release + # created with GITHUB_TOKEN starts no new workflow run. Also reachable on its own + # through workflow_dispatch with publish_tag, which is the recovery path once + # GitHub has retired the original run and "Re-run failed jobs" is gone. publish: name: 📦 Publish to PyPI - needs: [release, test-unit, test-integration] - if: needs.release.outputs.release_created == 'true' + needs: release + if: >- + !cancelled() && + (inputs.publish_tag != '' || needs.release.outputs.release_created == 'true') runs-on: ubuntu-latest + timeout-minutes: 15 # PyPI Trusted Publishing is bound to this workflow file name and this environment. environment: pypi permissions: contents: read id-token: write steps: + - name: Resolve tag + id: target + env: + PUBLISH_TAG: ${{ inputs.publish_tag }} + RELEASE_TAG: ${{ needs.release.outputs.tag_name }} + run: | + tag="${PUBLISH_TAG:-$RELEASE_TAG}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v5 with: - ref: ${{ needs.release.outputs.tag_name }} + ref: ${{ steps.target.outputs.tag }} # uv build and uv publish execute project and dependency code, and nothing # here writes to the repository, so do not leave GITHUB_TOKEN in .git/config. persist-credentials: false @@ -75,7 +155,7 @@ jobs: - uses: ./.github/actions/python-uv-setup - name: Build distributions - run: rm -rf dist && uv build + run: uv build - name: Publish to PyPI (Trusted Publishing) run: uv publish @@ -84,5 +164,5 @@ jobs: env: UV_NO_SOURCES: "1" run: | - uv pip install "getstream==${{ needs.release.outputs.version }}" || \ + uv pip install "getstream==${{ steps.target.outputs.version }}" || \ echo "WARNING: pip install verification failed (PyPI index may need a moment to propagate)" diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index f6dc9daf..4ba62869 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -6,6 +6,11 @@ on: description: 'pytest -m expression (e.g., `not integration` or `integration`)' required: true type: string + ref: + description: 'Git ref to check out. Empty means the default for the triggering event.' + required: false + type: string + default: '' secrets: { } concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.marker }} @@ -25,6 +30,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} - name: Install dependencies uses: ./.github/actions/python-uv-setup - name: Run linter @@ -36,6 +43,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} - name: Install dependencies uses: ./.github/actions/python-uv-setup - name: Run type checker @@ -57,6 +66,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} - name: Install dependencies uses: ./.github/actions/python-uv-setup with: @@ -83,6 +94,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} - name: Install dependencies uses: ./.github/actions/python-uv-setup with: From e8614a9dcae97dc22b75af6c07e7ad46229fd824 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Fri, 11 Sep 2026 17:27:59 +0200 Subject: [PATCH 4/6] docs: backfill the changelog gap and drop the manual release instructions CHANGELOG.md stopped at 4.2.0 while 6.1.0 was released, so the first generated section would have sat on top of a two-major hole that nothing would ever fill. Backfills 4.3.0 through 6.1.0 from the tags. DEVELOPMENT.md still described tagging and publishing by hand, which now collides with release-please's tagging and leaves the manifest behind the released version. --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++-- DEVELOPMENT.md | 14 +++++--------- README.md | 35 ++++++++++++++++++++++++++++++----- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccf3b7a..bdcbfcc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,44 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Entries from 6.1.1 on are generated by release-please from commit messages; earlier +ones were written by hand in the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +format. + +## [6.1.0] - 2026-09-09 + +### Changed + +- Regenerated the OpenAPI client (FEEDS-1763.1). + +## [6.0.0] - 2026-09-03 + +### Changed + +- **Breaking:** regenerated the OpenAPI client (FEEDS-1830). + +### Fixed + +- Scoped the `F401`/`UP035` lint suppressions to the generated models module. + +## [5.1.0] - 2026-08-25 + +### Changed + +- Regenerated the OpenAPI client (CHA-4947). + +## [5.0.0] - 2026-08-17 + +### Changed + +- **Breaking:** regenerated from chat v235.17.1 (#281). + +## [4.3.0] - 2026-08-12 + +### Fixed + +- Name the HTTP status when the error body is not JSON (#280). ## [4.2.0] - 2026-07-24 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c3d02d84..d41ddd09 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -68,16 +68,12 @@ handle the video/non-video test split or manual test exclusions. Prefer `make` t ## Release -Create a new release on Github, CI handles the rest. If you do need to do it manually follow these instructions: +Releases are generated by release-please and published by CI. See the Releases +section of `README.md`. -``` -rm -rf dist -git tag v0.0.15 -uv run hatch version # this should show the right version -git push origin main --tags -uv build --all -uv publish -``` +Do not tag or publish by hand. A hand-pushed `vX.Y.Z` tag collides with +release-please's own tagging and leaves `.release-please-manifest.json` behind the +released version. ## OpenAPI & Protobuf diff --git a/README.md b/README.md index 32ac2838..bc9a53a2 100644 --- a/README.md +++ b/README.md @@ -259,11 +259,36 @@ uv run pytest tests/test_video.py::test_specific_function -v Releases are driven by [release-please](https://github.com/googleapis/release-please). -- Merge PRs to `main` with conventional-commit titles. The PR title becomes the squash commit subject and decides the next version: `feat:` is a minor, `fix:` and `perf:` are a patch, `feat!:` or `(scope)!:` is a major. Other types (`chore`, `ci`, `docs`, `test`, `refactor`) ship nothing. -- release-please keeps a Release PR open with the version bump in `pyproject.toml`, `uv.lock` and `CHANGELOG.md`. It is opened by `github-actions[bot]`, so approve it and run its held checks like any other PR. -- Merging the Release PR creates the tag and the GitHub Release, runs lint, type-check and the unit and integration matrix on the tagged commit, then publishes to PyPI via Trusted Publishing (OIDC). A failed publish is retried with "Re-run failed jobs" on that workflow run. - -To force a specific version, type `Release-As: X.Y.Z` in the commit message box of the squash dialog when merging a PR; the PR description is not copied there. To hotfix while `main` carries unreleased work, branch `N.x` from the last tag, cherry-pick the fix, and merge the Release PR that release-please opens against that branch. +- Merge PRs to `main` with conventional-commit titles. The repo is squash-only with + `squash_merge_commit_title: PR_TITLE`, so the PR title becomes the commit subject and + decides the next version: `feat:` is a minor, `fix:` and `perf:` are a patch, `feat!:` + or `(scope)!:` is a major. Other types (`chore`, `ci`, `docs`, `test`, + `refactor`) ship nothing. Both settings are load-bearing: release-please reads commit + messages, never PR titles, and a merge commit's subject is not conventional. +- release-please keeps a Release PR open with the version bump in `pyproject.toml`, + `uv.lock` and `CHANGELOG.md`. It is opened by `github-actions[bot]`, so approve it and + run its held checks like any other PR. +- Merging the Release PR runs lint, type-check and the unit and integration matrix on + the commit that is about to be tagged. Only if that is green does the workflow create + the tag and the GitHub Release and publish to PyPI via Trusted Publishing (OIDC). The + order matters: a tag and a GitHub Release cannot be withdrawn, a failed publish can be + retried. + +To retry a publish that failed after the release was tagged, use "Re-run failed jobs" on +that workflow run. Once GitHub has retired the run, dispatch `Release` from `main` with +`publish_tag` set to the tag (for example `v6.1.1`), which builds and publishes that tag +without touching release-please. + +To force a specific version, type `Release-As: X.Y.Z` in the commit message box of the +squash dialog when merging a PR; the PR description is not copied there. To hotfix while +`main` carries unreleased work, branch `N.x` from the last tag, cherry-pick the fix, and +merge the Release PR that release-please opens against that branch. + +`last-release-sha` in `release-please-config.json` is temporary. `v6.1.0` sits on a bump +commit the previous workflow created off-branch and never pushed, so release-please +cannot reach it by walking `main` and would otherwise treat the whole history as +unreleased. Delete the key once a release-please-created release exists on `main`; the +walk stops at that release commit before it reaches the pin. ## License From 3613936fb3b79abb3323c0388dfc70fd9a5acdd1 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Fri, 11 Sep 2026 17:34:46 +0200 Subject: [PATCH 5/6] ci: keep the tested and tagged commit identical without a ref input Passing a ref into run_tests.yml gave CodeQL four high cache-poisoning alerts: the reusable workflow checks out that ref and then populates the uv cache the default branch reuses. The input is unreachable from untrusted data here, but it is a real class and not worth carrying for the edge it closed. The tests run on the workflow's own commit again, and the release job refuses to tag when that is not the commit the pending Release PR would be tagged at. On the path that gets there, a push of the Release PR's merge, they are the same commit, so the guarantee is stronger than the ref input gave: the tagged tree is always a tree the suite ran on. --- .github/workflows/release.yml | 19 +++++++++++++++++-- .github/workflows/run_tests.yml | 13 ------------- README.md | 9 +++++---- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 550dede0..5d694bb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,7 +81,6 @@ jobs: uses: ./.github/workflows/run_tests.yml with: marker: 'not integration' - ref: ${{ needs.detect.outputs.sha }} secrets: inherit test-integration: @@ -91,7 +90,6 @@ jobs: uses: ./.github/workflows/run_tests.yml with: marker: 'integration' - ref: ${{ needs.detect.outputs.sha }} secrets: inherit # Irreversible half. @@ -109,6 +107,23 @@ jobs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} steps: + # The suite ran against this workflow's own commit, while the tag lands on the + # merged Release PR's merge commit. They are the same commit on the path that + # gets here, a push of that merge. They diverge on a dispatch after the branch + # has moved, which would tag a tree nothing tested, so refuse instead. Recovery + # is "Re-run failed jobs" on the run for the merge itself. + - name: Refuse to tag a commit the suite did not run on + env: + TESTED: ${{ github.sha }} + PENDING: ${{ needs.detect.outputs.sha }} + run: | + if [ "$TESTED" != "$PENDING" ]; then + echo "::error::The pending release is tagged at $PENDING but this run tested $TESTED." + echo "::error::Re-run the workflow run for $PENDING instead of dispatching from the branch tip." + exit 1 + fi + echo "Tagging $PENDING, which is the commit the suite ran on." + - uses: googleapis/release-please-action@v4 id: release with: diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 4ba62869..f6dc9daf 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -6,11 +6,6 @@ on: description: 'pytest -m expression (e.g., `not integration` or `integration`)' required: true type: string - ref: - description: 'Git ref to check out. Empty means the default for the triggering event.' - required: false - type: string - default: '' secrets: { } concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.marker }} @@ -30,8 +25,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref }} - name: Install dependencies uses: ./.github/actions/python-uv-setup - name: Run linter @@ -43,8 +36,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref }} - name: Install dependencies uses: ./.github/actions/python-uv-setup - name: Run type checker @@ -66,8 +57,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref }} - name: Install dependencies uses: ./.github/actions/python-uv-setup with: @@ -94,8 +83,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref }} - name: Install dependencies uses: ./.github/actions/python-uv-setup with: diff --git a/README.md b/README.md index bc9a53a2..642a3054 100644 --- a/README.md +++ b/README.md @@ -269,10 +269,11 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl `uv.lock` and `CHANGELOG.md`. It is opened by `github-actions[bot]`, so approve it and run its held checks like any other PR. - Merging the Release PR runs lint, type-check and the unit and integration matrix on - the commit that is about to be tagged. Only if that is green does the workflow create - the tag and the GitHub Release and publish to PyPI via Trusted Publishing (OIDC). The - order matters: a tag and a GitHub Release cannot be withdrawn, a failed publish can be - retried. + that merge commit, which is the commit the tag will point at. Only if that is green + does the workflow create the tag and the GitHub Release and publish to PyPI via + Trusted Publishing (OIDC). The order matters: a tag and a GitHub Release cannot be + withdrawn, a failed publish can be retried. If a later dispatch would tag a commit + this run did not test, it fails rather than tagging it. To retry a publish that failed after the release was tagged, use "Re-run failed jobs" on that workflow run. Once GitHub has retired the run, dispatch `Release` from `main` with From 60db2964877d9efc56add74da03a68570a2b5eba Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Fri, 11 Sep 2026 17:43:00 +0200 Subject: [PATCH 6/6] ci: run the unit workflow on pull_request only ci.yml triggered on both push and pull_request, so every PR commit ran the suite twice on the same SHA. On 3613936 the two runs started five seconds apart and the pull_request one failed three legs on live-API 500s and read timeouts while the push one passed, which is the shared-app race in its plainest form. pull_request covers PR branches, and on the merge ref rather than the branch tip. main and *.x are covered by release.yml, which runs this same reusable workflow as the gate before tagging. --- .github/workflows/ci.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4686c5a9..748ff652 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,9 @@ name: CI (unit) +# pull_request only. A push trigger alongside it ran the whole suite twice on the same +# SHA, and the duplicate legs raced each other against the Stream app several SDK repos +# share. main and *.x are covered by release.yml, which runs this same reusable workflow +# as the gate before tagging. on: - # main and *.x are excluded: release.yml runs the same reusable workflow on those - # branches as the gate before tagging, and both firing on one push would double the - # live legs against the Stream app several SDK repos share. - push: - branches-ignore: [ main, "*.x" ] pull_request: branches: [ "**" ]