From ebfda1121b220ac9d64ae15db5440425a7afe64f Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Thu, 17 Sep 2026 10:07:38 +0200 Subject: [PATCH 1/2] fix(ci): find the pending release by label instead of by page detect listed one page of closed PRs and filtered the label client-side, so a release that had slipped past that page read as nothing to release and the run exited green without tagging. Brings the job to the shape the other five SDKs share. --- .github/workflows/release.yml | 88 +++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d694bb6..46c62f5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,12 +20,13 @@ concurrency: cancel-in-progress: false jobs: - # Reversible half: keep the Release PR current. Never gated. + # Reversible half: keep the Release PR current. Stands down only while a release is + # already pending, because until that one is tagged there is no release commit to stop + # the walk at and it would propose the same commits again in a second Release PR. release-pr: name: Release PR - if: >- - (github.event_name == 'push' || inputs.publish_tag == '') && - (github.ref_name == 'main' || endsWith(github.ref_name, '.x')) + needs: detect + if: needs.detect.outputs.pending != 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -42,8 +43,9 @@ jobs: # 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. + # 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. detect: name: Detect pending release if: >- @@ -56,28 +58,61 @@ jobs: pull-requests: read outputs: pending: ${{ steps.find.outputs.pending }} - sha: ${{ steps.find.outputs.sha }} + ready: ${{ steps.find.outputs.ready }} steps: - name: Find a merged Release PR waiting to be tagged id: find env: GH_TOKEN: ${{ github.token }} + BASE: ${{ github.ref_name }} + HEAD_SHA: ${{ github.sha }} 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')" + pending=false + ready=false + + # Query the label directly. Listing closed PRs and filtering client-side loses + # a release that has slipped past the first page, which reads as "nothing to + # release" and passes. Filter before picking, too: a hotfix branch can hold + # its own pending release, and taking the newest label match would drop this + # branch's release on every run until the other one clears. + nums="$(gh api "repos/${GITHUB_REPOSITORY}/issues" \ + -X GET -f state=closed -f labels='autorelease: pending' -f per_page=20 \ + --jq '.[] | select(.pull_request != null) | .number')" + + num="" + sha="" + for n in $nums; do + sha="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${n}" \ + --jq 'select(.merged_at != null and .base.ref == env.BASE) | .merge_commit_sha // empty')" + if [ -n "$sha" ]; then + num="$n" + break + fi + done + if [ -z "$sha" ]; then - echo "No pending release." - echo "pending=false" >> "$GITHUB_OUTPUT" - exit 0 + 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. + 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." + else + pending=true + ready=true + echo "Pending release #${num} will be tagged at ${sha}." fi - echo "Pending release will be tagged at $sha." - echo "pending=true" >> "$GITHUB_OUTPUT" - echo "sha=$sha" >> "$GITHUB_OUTPUT" + + { + echo "pending=${pending}" + echo "ready=${ready}" + } >> "$GITHUB_OUTPUT" test-unit: name: Test (unit) needs: detect - if: needs.detect.outputs.pending == 'true' + if: needs.detect.outputs.ready == 'true' uses: ./.github/workflows/run_tests.yml with: marker: 'not integration' @@ -86,7 +121,7 @@ jobs: test-integration: name: Test (integration) needs: detect - if: needs.detect.outputs.pending == 'true' + if: needs.detect.outputs.ready == 'true' uses: ./.github/workflows/run_tests.yml with: marker: 'integration' @@ -96,7 +131,7 @@ jobs: release: name: 🚀 Tag and release needs: [detect, test-unit, test-integration] - if: needs.detect.outputs.pending == 'true' + if: needs.detect.outputs.ready == 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -107,23 +142,6 @@ 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: From dbb3ed858628c7ebbfc8a6039bd7458d7ae518a9 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Thu, 17 Sep 2026 10:20:59 +0200 Subject: [PATCH 2/2] fix(ci): paginate the pending-release query and grant issues read The label is applied when the Release PR opens, not when it merges, so closed-unmerged Release PRs hold slots in the listing forever and one page eventually stops containing the real pending release. detect also read /issues without the issues scope, and an unguarded gh api assignment under bash -e would fail the job and skip release-pr with it. --- .github/workflows/release.yml | 50 ++++++++++++++++++++++++++--------- README.md | 11 ++++++-- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46c62f5d..bbdb0a3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,15 @@ jobs: release-pr: name: Release PR needs: detect - if: needs.detect.outputs.pending != 'true' + # No status function of its own would mean an implicit success(), so one transient + # gh api error inside detect would stop the reversible half too. Its own event and + # ref guard still has to be repeated here, because detect is skipped on the + # publish_tag path and this job must skip with it. + if: >- + !cancelled() && + (github.event_name == 'push' || inputs.publish_tag == '') && + (github.ref_name == 'main' || endsWith(github.ref_name, '.x')) && + needs.detect.outputs.pending != 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -56,6 +64,7 @@ jobs: permissions: contents: read pull-requests: read + issues: read outputs: pending: ${{ steps.find.outputs.pending }} ready: ${{ steps.find.outputs.ready }} @@ -70,20 +79,30 @@ jobs: pending=false ready=false - # Query the label directly. Listing closed PRs and filtering client-side loses - # a release that has slipped past the first page, which reads as "nothing to - # release" and passes. Filter before picking, too: a hotfix branch can hold - # its own pending release, and taking the newest label match would drop this - # branch's release on every run until the other one clears. - nums="$(gh api "repos/${GITHUB_REPOSITORY}/issues" \ - -X GET -f state=closed -f labels='autorelease: pending' -f per_page=20 \ - --jq '.[] | select(.pull_request != null) | .number')" - + # Query the label directly, and page: release-please applies the label when it + # opens the Release PR, not when it merges, so every Release PR closed without + # merging keeps it forever and holds a slot in this listing. One page would + # eventually stop containing the genuinely pending release, which reads as + # "nothing to release" and passes. merged_at comes back in the listing, so + # filtering on it here keeps the per-PR lookups below to real candidates. + nums="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues" \ + -X GET -f state=closed -f labels='autorelease: pending' -f per_page=100 \ + --jq '.[] | select(.pull_request.merged_at != null) | .number')" + + # The base branch is not in that listing, so each candidate still needs a + # lookup: a hotfix branch can hold its own pending release, and taking the + # newest label match would drop this branch's release until the other clears. num="" sha="" for n in $nums; do - sha="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${n}" \ - --jq 'select(.merged_at != null and .base.ref == env.BASE) | .merge_commit_sha // empty')" + # Under `bash -e` an unguarded assignment from a non-2xx would abort the + # step, which would fail detect and skip release-pr with it. + if ! sha="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${n}" \ + --jq 'select(.base.ref == env.BASE) | .merge_commit_sha // empty')"; then + echo "::warning::Could not read PR #${n}; skipping it." + sha="" + continue + fi if [ -n "$sha" ]; then num="$n" break @@ -98,6 +117,13 @@ jobs: # would redden every later 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." + { + echo "### Release stuck" + echo + echo "Release PR #${num} merged at \`${sha}\` and was never tagged, so no Release PR will be opened or refreshed until it clears." + echo + echo "Re-run the \`Release\` run for \`${sha}\`. If that commit is genuinely broken, remove the \`autorelease: pending\` label from #${num} by hand and release forward." + } >> "$GITHUB_STEP_SUMMARY" else pending=true ready=true diff --git a/README.md b/README.md index 642a3054..51a38acf 100644 --- a/README.md +++ b/README.md @@ -272,8 +272,15 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl 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. + 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. + +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 +is genuinely broken, remove the `autorelease: pending` label from the merged Release PR +by hand, then release forward; nothing clears that state automatically. 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