From 4c920d03a026397e010d196f1231a886eb512f51 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 16:54:35 +0200 Subject: [PATCH 1/4] ci: stop running the unit lane before tagging The tagged commit is the Release PR's merge commit. Merges are squashed onto an up-to-date branch and release-please refreshes its PR on every push, so that commit's tree is the Release PR's tree: the version bump and changelog on top of an already-tested default branch. The Release PR already skips the lane, so running it after merge re-tested the same tree at the one point where a failure could no longer be fixed on the PR and instead left the release stuck on autorelease: pending. Release now matches chat's: merge, tag, publish. --- .github/workflows/ci.yml | 6 ++---- .github/workflows/release.yml | 14 ++------------ .github/workflows/run_integration.yml | 3 +-- DEVELOPMENT.md | 5 ++--- README.md | 12 +++--------- 5 files changed, 10 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 841ea806..98b4649a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ 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. +# share. A merge to main runs nothing here. on: pull_request: branches: [ "**" ] @@ -15,8 +14,7 @@ jobs: # Skipped on a Release PR: it only bumps the version and rewrites the changelog. Not # keyed on github.actor, which is the pusher: clicking Update branch reattributes the # merge commit to whoever clicked, and the skip would stop firing on the normal release - # path. A human commit pushed onto a Release PR is therefore untested until release.yml - # runs the lane on the merge commit. + # path. A human commit pushed onto a Release PR is therefore released untested. unit: if: >- ${{ !(github.event.pull_request.user.login == 'github-actions[bot]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c511f5ac..4722a69b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,11 +44,7 @@ jobs: 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 while the suite runs on this - # workflow's own commit, so `ready` also requires those to be the same commit. They - # are, on the path that matters: the push of that merge. + # Tagging and the GitHub Release are irreversible, so they run only on the push that merged the Release PR: `ready` requires the pending release's merge commit to be this run's commit. No test run gates them, because the Release PR adds only the version bump and changelog to an already-tested default branch. detect: name: Detect pending release if: >- @@ -159,16 +155,10 @@ jobs: echo "ready=${ready}" } >> "$GITHUB_OUTPUT" - test-unit: - name: Test (unit) - needs: detect - if: needs.detect.outputs.ready == 'true' - uses: ./.github/workflows/run_tests.yml - # Irreversible half. release: name: 🚀 Tag and release - needs: [detect, test-unit] + needs: detect if: needs.detect.outputs.ready == 'true' runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/run_integration.yml b/.github/workflows/run_integration.yml index 15154d4e..8526e419 100644 --- a/.github/workflows/run_integration.yml +++ b/.github/workflows/run_integration.yml @@ -16,8 +16,7 @@ env: permissions: contents: read -# The only place `-m integration` runs: daily and before a tag, never as a status check on a -# pull request. See DEVELOPMENT.md for why it gates nothing. +# The only place `-m integration` runs: daily, never as a status check on a pull request. See DEVELOPMENT.md for why it gates nothing. jobs: integration-non-video: name: 🧪 Non-video integration (${{ matrix.python-version }}) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index aa40b09c..841fd297 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,7 +44,7 @@ make test-prometheus # requires getstream[telemetry] deps | --- | --- | --- | | Pull request | `run_tests.yml`: ruff, ty, and `-m "not integration"` on five Python versions | yes, `🧪 Tests` is the required check | | Daily at 09:00 UTC | `run_integration.yml`: `-m integration`, both credential sets | no | -| Push to `main` with a release pending | the unit lane | yes, it gates the tag | +| Release PR merged | nothing, it tags and publishes | no | `@pytest.mark.integration` means one thing: the test talks to a live Stream app. The unit lane therefore runs with no credentials, no `environment:` and no `STREAM_*`. Keep it that @@ -56,8 +56,7 @@ another repo's run or a backend regression can redden it with nothing wrong here run before a tag either. A red daily run opens an issue titled "Daily integration run is red". Fix it, do not route around it. -A Release PR skips the lane and `🧪 Tests` passes in seconds on a `skipped` result. The -merge commit still runs it before the tag. +A Release PR skips the lane and `🧪 Tests` passes in seconds on a `skipped` result. Merging it tags and publishes with no further test run: it adds only the version bump and changelog to an already-tested `main`. ### Linting and type checking diff --git a/README.md b/README.md index 51a38acf..e4477edd 100644 --- a/README.md +++ b/README.md @@ -268,17 +268,11 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl - 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 - 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 the commit waiting to be tagged is not - the one this run tested, the workflow stands down instead of tagging it, and says so in - the run summary. +- Merging the Release PR creates the tag and the GitHub Release on that merge commit and publishes to PyPI via Trusted Publishing (OIDC), with no further test run: the Release PR adds only the version bump and changelog to an already-tested `main`. A tag and a GitHub Release cannot be withdrawn, a failed publish can be retried. If the commit waiting to be tagged is not this run's commit, the workflow stands down instead of tagging it, and says so in the run summary. While a merged Release PR is waiting to be tagged, no new Release PR is opened or -refreshed, so that release-please has a release commit to stop its walk at. If the suite -failed on that merge commit, use "Re-run failed jobs" on its `Release` run. If the commit +refreshed, so that release-please has a release commit to stop its walk at. If the release +job failed on that merge commit, use "Re-run failed jobs" on its `Release` run. If the commit is genuinely broken, remove the `autorelease: pending` label from the merged Release PR by hand, then release forward; nothing clears that state automatically. From 00008ec460d3ef06339d2f8f46b8ebf56bf47d24 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:34:52 +0200 Subject: [PATCH 2/4] ci: skip Release PRs by changed files and keep the pre-tag run for hotfixes - The Release PR skip now also requires every changed file to be one release-please writes. A release-only job lists the PR's files; a code change pushed onto a Release PR by hand, an unexpected file or a failed lookup runs the unit lane instead. Checked against the open Release PRs, which all still skip. - Releases from an N.x branch run the unit lane before tagging again. Hotfix commits are pushed there without a PR, so nothing else tested them. Releases from the default branch still tag directly. - The detect stand-down comment no longer refers to a test run, and the java and net docs no longer say publish_tag can fix a build that does not compile. --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++--------- .github/workflows/release.yml | 17 +++++++---- DEVELOPMENT.md | 4 +-- README.md | 2 +- 4 files changed, 55 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98b4649a..3a44c4e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,42 +14,68 @@ jobs: # Skipped on a Release PR: it only bumps the version and rewrites the changelog. Not # keyed on github.actor, which is the pusher: clicking Update branch reattributes the # merge commit to whoever clicked, and the skip would stop firing on the normal release - # path. A human commit pushed onto a Release PR is therefore released untested. - unit: + # path. + # The skip also requires every changed file to be one release-please writes, so a code change pushed onto a Release PR by hand runs the unit lane. An unexpected file or a failed lookup fails toward running it. + release-only: if: >- - ${{ !(github.event.pull_request.user.login == 'github-actions[bot]' + ${{ github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--')) }} + && startsWith(github.head_ref, 'release-please--') }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + skip: ${{ steps.files.outputs.skip }} + steps: + - id: files + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + ALLOWED: '^(CHANGELOG\.md|\.release-please-manifest\.json|pyproject\.toml|uv\.lock)$' + run: | + if ! files="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --paginate --jq '.[].filename')"; then + echo "::warning::Could not list this PR's files; running the unit lane." + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + other="$(printf '%s\n' "$files" | grep -Ev "$ALLOWED" || true)" + if [ -n "$files" ] && [ -z "$other" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Files outside what release-please writes changed, so the unit lane runs: ${other//$'\n'/ }" + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + unit: + needs: release-only + if: ${{ !cancelled() && needs.release-only.outputs.skip != 'true' }} uses: ./.github/workflows/run_tests.yml # The one required status check on main, and it carries no matrix on purpose: a matrix job # skipped by `if:` publishes a single check run with the template unexpanded, so per-leg - # contexts could never be satisfied on a Release PR. `skipped` is accepted only when the - # condition above holds, repeated here because Actions cannot share an expression; if the - # two drift this fails, which is the safe direction. + # contexts could never be satisfied on a Release PR. `skipped` is accepted only when release-only confirmed a release-please-only diff. tests-passed: name: 🧪 Tests - needs: unit + needs: [release-only, unit] if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - name: Check the unit lane env: RESULT: ${{ needs.unit.result }} - RELEASE_PR: >- - ${{ github.event.pull_request.user.login == 'github-actions[bot]' - && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--') }} + SKIP: ${{ needs.release-only.outputs.skip }} run: | case "$RESULT" in success) echo "unit lane passed" ;; skipped) - if [ "$RELEASE_PR" = "true" ]; then + if [ "$SKIP" = "true" ]; then echo "release pr: unit lane skipped by design" else - echo "::error::the unit lane was skipped on a PR that is not a Release PR" + echo "::error::the unit lane was skipped without release-only confirming a release-please-only diff" exit 1 fi ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4722a69b..91f2046e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: target-branch: ${{ github.ref_name }} skip-github-release: true - # Tagging and the GitHub Release are irreversible, so they run only on the push that merged the Release PR: `ready` requires the pending release's merge commit to be this run's commit. No test run gates them, because the Release PR adds only the version bump and changelog to an already-tested default branch. + # Tagging and the GitHub Release are irreversible, so they run only on the push that merged the Release PR: `ready` requires the pending release's merge commit to be this run's commit. A release from the default branch has no test run, because the Release PR adds only the version bump and changelog to already-tested code; a hotfix release from `N.x` runs the unit lane first, because hotfix commits are pushed without a PR. detect: name: Detect pending release if: >- @@ -119,8 +119,7 @@ jobs: echo "No pending release on ${BASE}." elif [ "$sha" != "$HEAD_SHA" ]; then # Reached when an earlier release run failed after the Release PR merged. - # Tagging $sha here would tag a tree this run never tested, and failing - # would redden every later push, so stand down and say why. + # Finishing it from a later push would retry a deterministic failure (a refused major tag, a broken build) on every push, so stand down and say why. pending=true echo "::warning::Release PR #${num} is still pending at ${sha}, which is not this run's commit ${HEAD_SHA}. Re-run the workflow run for ${sha} to finish that release." { @@ -155,11 +154,19 @@ jobs: echo "ready=${ready}" } >> "$GITHUB_OUTPUT" + tests: + name: Tests (hotfix only) + needs: detect + if: needs.detect.outputs.ready == 'true' && github.ref_name != github.event.repository.default_branch + uses: ./.github/workflows/run_tests.yml + # Irreversible half. release: name: 🚀 Tag and release - needs: detect - if: needs.detect.outputs.ready == 'true' + needs: [detect, tests] + if: >- + ${{ !cancelled() && needs.detect.outputs.ready == 'true' + && (needs.tests.result == 'success' || needs.tests.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 841fd297..bbd6a087 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,7 +44,7 @@ make test-prometheus # requires getstream[telemetry] deps | --- | --- | --- | | Pull request | `run_tests.yml`: ruff, ty, and `-m "not integration"` on five Python versions | yes, `🧪 Tests` is the required check | | Daily at 09:00 UTC | `run_integration.yml`: `-m integration`, both credential sets | no | -| Release PR merged | nothing, it tags and publishes | no | +| Release PR merged | nothing on the default branch, the unit lane on `N.x` | `N.x` only | `@pytest.mark.integration` means one thing: the test talks to a live Stream app. The unit lane therefore runs with no credentials, no `environment:` and no `STREAM_*`. Keep it that @@ -56,7 +56,7 @@ another repo's run or a backend regression can redden it with nothing wrong here run before a tag either. A red daily run opens an issue titled "Daily integration run is red". Fix it, do not route around it. -A Release PR skips the lane and `🧪 Tests` passes in seconds on a `skipped` result. Merging it tags and publishes with no further test run: it adds only the version bump and changelog to an already-tested `main`. +A Release PR skips the lane and `🧪 Tests` passes in seconds on a `skipped` result. The skip only applies while every changed file is one release-please writes, so a code change pushed onto a Release PR by hand runs the unit lane like any other PR. Merging it tags and publishes with no further test run: it adds only the version bump and changelog to an already-tested `main`. A hotfix release from `N.x` runs the unit lane first, since its commits were pushed without a PR. ### Linting and type checking diff --git a/README.md b/README.md index e4477edd..71f8765e 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl - 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 on that merge commit and publishes to PyPI via Trusted Publishing (OIDC), with no further test run: the Release PR adds only the version bump and changelog to an already-tested `main`. A tag and a GitHub Release cannot be withdrawn, a failed publish can be retried. If the commit waiting to be tagged is not this run's commit, the workflow stands down instead of tagging it, and says so in the run summary. +- Merging the Release PR creates the tag and the GitHub Release on that merge commit and publishes to PyPI via Trusted Publishing (OIDC), with no further test run: the Release PR adds only the version bump and changelog to an already-tested `main`. A hotfix release from `N.x` runs the unit lane first, since its commits were pushed without a PR. A tag and a GitHub Release cannot be withdrawn, a failed publish can be retried. If the commit waiting to be tagged is not this run's commit, the workflow stands down instead of tagging it, and says so in the run summary. While a merged Release PR is waiting to be tagged, no new Release PR is opened or refreshed, so that release-please has a release commit to stop its walk at. If the release From 851cd9c7ecb4733a32d5aafc231168b2c2b31e39 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:51:32 +0200 Subject: [PATCH 3/4] ci: never tag when detect failed release accepted a skipped tests job under !cancelled(), so a detect job that wrote ready=true and then failed a later step still reached the tag. In getstream-go that later step is the go.mod major check, so an uninstallable major would have been tagged permanently. release now also requires needs.detect.result == 'success'. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91f2046e..c3a95c16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,7 +165,7 @@ jobs: name: 🚀 Tag and release needs: [detect, tests] if: >- - ${{ !cancelled() && needs.detect.outputs.ready == 'true' + ${{ !cancelled() && needs.detect.result == 'success' && needs.detect.outputs.ready == 'true' && (needs.tests.result == 'success' || needs.tests.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 5 From be87011fb9e23c8ab8000b64107e08285d16c94e Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:57:38 +0200 Subject: [PATCH 4/4] ci: skip a Release PR only when each version file changes nothing but its version The allowlist let a whole file through, and pyproject.toml, uv.lock, composer.json, the csproj and Client.cs also hold dependencies or client code, so a hand-pushed dependency bump still skipped the lane. Now every added line in a version file must carry the new version from the manifest, and with versions masked the removed lines must match the added ones one for one. The file list reaches the inline script through a temp file, since a heredoc on python3 takes over its stdin. Checked end to end with the step as written: the open Release PRs in stream-py, getstream-php, getstream-net and getstream-go skip; ordinary PRs, an added dependency line, a dropped dependency line, an injected line and an extra file all run the lane. --- .github/workflows/ci.yml | 57 +++++++++++++++++++++++++++++++++------- DEVELOPMENT.md | 2 +- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a44c4e6..acfc4829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: # keyed on github.actor, which is the pusher: clicking Update branch reattributes the # merge commit to whoever clicked, and the skip would stop firing on the normal release # path. - # The skip also requires every changed file to be one release-please writes, so a code change pushed onto a Release PR by hand runs the unit lane. An unexpected file or a failed lookup fails toward running it. + # The skip also requires the diff to be only what release-please writes: the changelog, the manifest, and in each version file nothing but the version line. A hand-pushed code or dependency change, an unexpected file or a failed lookup runs the unit lane. release-only: if: >- ${{ github.event.pull_request.user.login == 'github-actions[bot]' @@ -33,20 +33,57 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} - ALLOWED: '^(CHANGELOG\.md|\.release-please-manifest\.json|pyproject\.toml|uv\.lock)$' + VERSION_FILES: pyproject.toml uv.lock run: | - if ! files="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --paginate --jq '.[].filename')"; then + export FILES="$RUNNER_TEMP/pr-files.jsonl" + if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --paginate --jq '.[] | @json' > "$FILES"; then echo "::warning::Could not list this PR's files; running the unit lane." echo "skip=false" >> "$GITHUB_OUTPUT" exit 0 fi - other="$(printf '%s\n' "$files" | grep -Ev "$ALLOWED" || true)" - if [ -n "$files" ] && [ -z "$other" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::Files outside what release-please writes changed, so the unit lane runs: ${other//$'\n'/ }" - echo "skip=false" >> "$GITHUB_OUTPUT" - fi + python3 - <<'PY' + import json, os, re + + files = [json.loads(line) for line in open(os.environ["FILES"]) if line.strip()] + version_files = set(os.environ["VERSION_FILES"].split()) + free = {"CHANGELOG.md", ".release-please-manifest.json"} + version_token = re.compile(r"v?\d+(?:\.\d+)+(?:-[0-9A-Za-z.]+)?") + + + def lines(f, sign): + return [l[1:] for l in (f.get("patch") or "").split("\n") if l.startswith(sign)] + + + def decide(): + manifest = next((f for f in files if f["filename"] == ".release-please-manifest.json"), None) + new = re.findall(r'"\.":\s*"([^"]+)"', "\n".join(lines(manifest, "+"))) if manifest else [] + if len(new) != 1: + return "the manifest diff is not a single version bump" + has_new = re.compile(r"(?