From 23b6763f1226534def4d8bdda8c3f92503b5de42 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 16:54:53 +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 | 3 +-- .github/workflows/release.yml | 14 ++------------ README.md | 8 ++------ 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3cafce..604ec9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ name: ci # pull_request only. A push trigger alongside it ran the whole suite twice on the same -# SHA. A merge to master runs nothing here: release.yml runs the unit lane only when a -# release is pending, so the gate before a tag is covered. +# SHA. A merge to master runs nothing here. on: pull_request: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce748ab..3410f35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,11 +45,7 @@ jobs: target-branch: ${{ github.ref_name }} skip-github-release: true - # The tag 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. + # The tag 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: >- @@ -160,16 +156,10 @@ jobs: echo "ready=${ready}" } >> "$GITHUB_OUTPUT" - tests: - name: Tests - needs: detect - if: needs.detect.outputs.ready == 'true' - uses: ./.github/workflows/run_tests.yml - # 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/README.md b/README.md index da4d75e..8800022 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ CI follows the same split: | --- | --- | --- | | Pull request | `make lint` and `make test-unit` on PHP 8.1 to 8.3 | yes, `๐Ÿงช Tests` | | Daily at 12:00 UTC | `make test-integration` | no, a red run opens an issue | -| Push to `master` with a release pending | the unit lane | yes, it gates the tag | +| Release PR merged | nothing, it tags and publishes | no | ## Usage @@ -252,11 +252,7 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl reports `skipped` and `๐Ÿงช Tests` goes green without running a test. The skip keys on the PR author, so a commit pushed onto a Release PR by hand is skipped too and reaches `master` untested. -- Merging the Release PR runs lint and unit tests across PHP 8.1 to 8.3 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 announce the tag to Packagist. The - order matters: a tag and a GitHub Release cannot be withdrawn. Integration tests are - advisory and gate none of it. +- Merging the Release PR creates the tag and the GitHub Release on that merge commit and announces the tag to Packagist, with no further test run: the Release PR adds only the version bump and changelog to an already-tested `master`. A tag and a GitHub Release cannot be withdrawn. Packagist reads the tags itself, so the announce step only asks it to look now rather than on its own schedule. If it fails or the credentials are unset, the release still From b50f6fb2d5e2085fd112175015f7c0b959774aac Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:35:00 +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 | 51 ++++++++++++++++++++++++++--------- .github/workflows/release.yml | 17 ++++++++---- README.md | 8 +++--- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 604ec9b..50e3026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,22 +21,52 @@ jobs: # merge commit to whoever clicked, and the skip would stop firing on the normal 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. - unit: + # 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|composer\.json|src/Constant\.php)$' + 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 master. 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 when the condition above holds, repeated here because Actions cannot - # share an expression. `!cancelled()` rather than `always()`: a cancelled run must stay + # 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. tests-passed: name: ๐Ÿงช Tests - needs: unit + needs: [release-only, unit] if: ${{ !cancelled() }} runs-on: ubuntu-latest timeout-minutes: 5 @@ -44,20 +74,17 @@ jobs: - 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 3410f35..75e09c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: target-branch: ${{ github.ref_name }} skip-github-release: true - # The tag 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. + # The tag 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: >- @@ -120,8 +120,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." { @@ -156,11 +155,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/README.md b/README.md index 8800022..c4e052c 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ CI follows the same split: | --- | --- | --- | | Pull request | `make lint` and `make test-unit` on PHP 8.1 to 8.3 | yes, `๐Ÿงช Tests` | | Daily at 12:00 UTC | `make test-integration` | 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 | ## Usage @@ -249,10 +249,8 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl anchors on. - Its runs are created held at `action_required` until someone clicks **Approve and run**, because release-please opens the PR with `GITHUB_TOKEN`. The unit lane then - reports `skipped` and `๐Ÿงช Tests` goes green without running a test. The skip keys on - the PR author, so a commit pushed onto a Release PR by hand is skipped too and reaches - `master` untested. -- Merging the Release PR creates the tag and the GitHub Release on that merge commit and announces the tag to Packagist, with no further test run: the Release PR adds only the version bump and changelog to an already-tested `master`. A tag and a GitHub Release cannot be withdrawn. + reports `skipped` and `๐Ÿงช Tests` goes green without running a test. 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 the Release PR creates the tag and the GitHub Release on that merge commit and announces the tag to Packagist, with no further test run: the Release PR adds only the version bump and changelog to an already-tested `master`. 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. Packagist reads the tags itself, so the announce step only asks it to look now rather than on its own schedule. If it fails or the credentials are unset, the release still From a811f24cd4ba753d448220c2b5284c234051cb98 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:51:45 +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 75e09c8..916d1be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,7 +166,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 fca34e12023f4fd1b178f3403737eb53d3927849 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 23 Sep 2026 17:57:51 +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 +++++++++++++++++++++++++++++++++------- README.md | 2 +- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50e3026..877cf15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: # merge commit to whoever clicked, and the skip would stop firing on the normal 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. - # 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]' @@ -39,20 +39,57 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} - ALLOWED: '^(CHANGELOG\.md|\.release-please-manifest\.json|composer\.json|src/Constant\.php)$' + VERSION_FILES: composer.json src/Constant.php 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"(?