From 268d0eb68ecbabfdb0a1cf25246a1a20e6e1ee02 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 16:55:03 +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/release.yml | 16 ++-------------- CONTRIBUTING.md | 4 ++-- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e50fab5..37d2d5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,7 @@ jobs: target-branch: ${{ github.ref_name }} skip-github-release: true - # The tag is irreversible and, for Go, permanent: proxy.golang.org caches it forever. - # So the suite has to run before it, 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. + # The tag is irreversible and, for Go, permanent: proxy.golang.org caches it forever, 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: github.ref_name == 'main' || endsWith(github.ref_name, '.x') @@ -178,18 +174,10 @@ jobs: fi echo "Release $version will be tagged at $SHA, where go.mod is $module_path." - tests: - name: Tests - needs: detect - if: needs.detect.outputs.ready == 'true' - uses: ./.github/workflows/run_tests.yml - secrets: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # Irreversible half. release: name: ๐Ÿš€ Tag and release - needs: [detect, tests] + needs: detect if: needs.detect.outputs.ready == 'true' runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d2f062..85bf543 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ CI follows the same split: | --- | --- | --- | | Pull request | `go test -short` on Go 1.19 to 1.24 | yes, `๐Ÿงช Tests` | | Daily at 14:00 UTC | the full suite on Go 1.24 | no, a red run opens an issue | -| Push to `main` with a release pending | the unit lane | yes, it gates the tag | +| Release PR merged | nothing, it tags and publishes | no | The full suite requires at least two environment variables: `STREAM_API_KEY` and `STREAM_API_SECRET`. There are multiple ways to provide that: - simply set it in your current shell (`export STREAM_API_KEY=xyz`) @@ -63,7 +63,7 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl - release-please keeps a Release PR open with the version bump and the generated changelog. Review it. - The Release PR is opened by `github-actions[bot]`, so its CI runs are held at "action required". If the PR is behind `main`, clicking **Update branch** also releases them, because that commit is attributed to you; otherwise click **Approve and run**. It needs a code-owner approval like any other PR. - The unit lane does not run on a Release PR, whichever of those two paths you take, and `๐Ÿงช Tests` still reports satisfied. A Release PR only bumps the version and rewrites the changelog. `Lint`, `reviewdog`, CodeQL and the PR-title check still run. Label the PR `run-tests` if you want the unit lane anyway. -- Merge the Release PR. That creates the tag and the GitHub Release, and `proxy.golang.org` picks the tag up. There is no separate publish step. The unit lane does run before the tag: `release.yml` calls it on the merge commit, and a failure there leaves `autorelease: pending` set and needs the manual recovery below. Integration tests are advisory and gate none of it. +- Merge the Release PR. That creates the tag and the GitHub Release, and `proxy.golang.org` picks the tag up. There is no separate publish step and no further test run: the Release PR adds only the version bump and changelog to an already-tested `main`. Integration tests are advisory and gate none of it. Only `feat`, `fix`, `perf` and breaking changes produce a release (`revert` may also). A window of only `chore`, `ci`, `docs`, `test`, `refactor`, `style` or `build` commits produces no Release PR, which is intended. From a864fc3aa890fc1eb1b10ab48c824351c99f094e Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:37:04 +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 | 55 +++++++++++++++++++++++++---------- .github/workflows/release.yml | 19 ++++++++---- CONTRIBUTING.md | 6 ++-- 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3f2ddf..89eebf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,29 +23,57 @@ jobs: # release path. The author and head repo clauses are what make it unforgeable; the branch # name on its own would let any PR, a fork's included, call its branch release-please--x. # Label a Release PR `run-tests` to force the lane anyway. - # - # Keep this job id: it prefixes every check name the reusable workflow produces. - ci: + # 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: >- - ${{ contains(github.event.pull_request.labels.*.name, 'run-tests') - || !(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|version\.go)$' + 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 + + # Keep this job id: it prefixes every check name the reusable workflow produces. + ci: + needs: release-only + if: ${{ !cancelled() && (contains(github.event.pull_request.labels.*.name, 'run-tests') || needs.release-only.outputs.skip != 'true') }} uses: ./.github/workflows/run_tests.yml secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # The one required status check on main. 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; this job carries no matrix for that reason. `skipped` is accepted only on a - # Release PR, repeated here because Actions cannot share an expression. `!cancelled()` + # Release PR; this job carries no matrix for that reason. `skipped` is accepted only when release-only confirmed a release-please-only diff. `!cancelled()` # rather than `always()`: a cancelled run must stay red, not report a pass. Only a real run # of this workflow can republish it, which is why the run-tests escape hatch lives in # label_tests.yml: a label event reaching here would republish the context having tested # nothing, turning a red gate green. tests-passed: name: ๐Ÿงช Tests - needs: ci + needs: [release-only, ci] if: ${{ !cancelled() }} runs-on: ubuntu-latest timeout-minutes: 5 @@ -53,20 +81,17 @@ jobs: - name: Check the unit lane env: RESULT: ${{ needs.ci.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 37d2d5f..6b0000c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: target-branch: ${{ github.ref_name }} skip-github-release: true - # The tag is irreversible and, for Go, permanent: proxy.golang.org caches it forever, 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. + # The tag is irreversible and, for Go, permanent: proxy.golang.org caches it forever, 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: github.ref_name == 'main' || endsWith(github.ref_name, '.x') @@ -113,8 +113,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." { @@ -174,11 +173,21 @@ jobs: fi echo "Release $version will be tagged at $SHA, where go.mod is $module_path." + 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 + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # 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/CONTRIBUTING.md b/CONTRIBUTING.md index 85bf543..3f88883 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ CI follows the same split: | --- | --- | --- | | Pull request | `go test -short` on Go 1.19 to 1.24 | yes, `๐Ÿงช Tests` | | Daily at 14:00 UTC | the full suite on Go 1.24 | no, a red run opens an issue | -| 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 | The full suite requires at least two environment variables: `STREAM_API_KEY` and `STREAM_API_SECRET`. There are multiple ways to provide that: - simply set it in your current shell (`export STREAM_API_KEY=xyz`) @@ -62,8 +62,8 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl - Merge PRs to `main` with conventional-commit titles. The PR title becomes the commit subject and is what determines the next version, so a non-conventional title ships nothing. - release-please keeps a Release PR open with the version bump and the generated changelog. Review it. - The Release PR is opened by `github-actions[bot]`, so its CI runs are held at "action required". If the PR is behind `main`, clicking **Update branch** also releases them, because that commit is attributed to you; otherwise click **Approve and run**. It needs a code-owner approval like any other PR. -- The unit lane does not run on a Release PR, whichever of those two paths you take, and `๐Ÿงช Tests` still reports satisfied. A Release PR only bumps the version and rewrites the changelog. `Lint`, `reviewdog`, CodeQL and the PR-title check still run. Label the PR `run-tests` if you want the unit lane anyway. -- Merge the Release PR. That creates the tag and the GitHub Release, and `proxy.golang.org` picks the tag up. There is no separate publish step and no further test run: the Release PR adds only the version bump and changelog to an already-tested `main`. Integration tests are advisory and gate none of it. +- The unit lane does not run on a Release PR, whichever of those two paths you take, and `๐Ÿงช Tests` still reports satisfied. A Release PR only bumps the version and rewrites the changelog. 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. `Lint`, `reviewdog`, CodeQL and the PR-title check still run. Label the PR `run-tests` if you want the unit lane anyway. +- Merge the Release PR. That creates the tag and the GitHub Release, and `proxy.golang.org` picks the tag up. There is no separate publish step and 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. Integration tests are advisory and gate none of it. Only `feat`, `fix`, `perf` and breaking changes produce a release (`revert` may also). A window of only `chore`, `ci`, `docs`, `test`, `refactor`, `style` or `build` commits produces no Release PR, which is intended. From c0ce26ed3ad417821932f59eab527d68fcda123c Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:51:27 +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 6b0000c..4dd4e47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,7 +186,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 dc33a2d65b04c7073b9f8c5bb018ab0b04e4a610 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:57:59 +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 +++++++++++++++++++++++++++++++++------- CONTRIBUTING.md | 2 +- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89eebf6..dbf46cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: # release path. The author and head repo clauses are what make it unforgeable; the branch # name on its own would let any PR, a fork's included, call its branch release-please--x. # Label a Release PR `run-tests` to force the lane anyway. - # 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]' @@ -41,20 +41,57 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} - ALLOWED: '^(CHANGELOG\.md|\.release-please-manifest\.json|version\.go)$' + VERSION_FILES: version.go 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"(?