From 8128a9b9b601568c0c2b034b206f83c475a99f35 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Thu, 17 Sep 2026 10:42:30 +0200 Subject: [PATCH 1/3] fix(ci): paginate the pending-release query and guard its lookups Ports the hardening reviewed on stream-py#288. 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. --- .github/workflows/release.yml | 47 +++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11d6874..1ae5853 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 guard still + # has to be repeated here, because detect is skipped on the dispatch path and this + # job must skip with it. + if: >- + !cancelled() && + (github.event_name == 'push' || inputs.publish_tag == '') && + (github.ref_name == 'master' || endsWith(github.ref_name, '.x')) && + needs.detect.outputs.pending != 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -71,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 @@ -99,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 From 3b1f371a4a1023e37899e737f7252e6543262f15 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Thu, 17 Sep 2026 11:08:01 +0200 Subject: [PATCH 2/3] fix(ci): treat an unknown release state as pending, not as none The guarded lookups read a failed call as nothing to release, which is worse than the abort they replaced: release-pr would open a second Release PR on top of an untagged one. An unknown answer now stands down, release-pr gates on the explicit pending=false, the per-PR jq keeps its merged_at check against speculative merge commits on closed-unmerged PRs, and a stuck release is announced on its own Release PR. --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ae5853..6bfa219 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,15 +26,10 @@ jobs: release-pr: name: Release PR needs: detect - # 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 guard still - # has to be repeated here, because detect is skipped on the dispatch path and this - # job must skip with it. - if: >- - !cancelled() && - (github.event_name == 'push' || inputs.publish_tag == '') && - (github.ref_name == 'master' || endsWith(github.ref_name, '.x')) && - needs.detect.outputs.pending != 'true' + # Gate on the explicit value. If detect fails or is skipped the output is empty, and + # anything short of a definite "nothing pending" has to hold this job back, or it + # proposes a second Release PR on top of one that may still be untagged. + if: needs.detect.outputs.pending == 'false' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -63,7 +58,9 @@ jobs: timeout-minutes: 5 permissions: contents: read - pull-requests: read + # write, not read: a stuck release is announced on its own Release PR, because + # nothing else reaches a person without them opening the run first. + pull-requests: write issues: read outputs: pending: ${{ steps.find.outputs.pending }} @@ -78,6 +75,7 @@ jobs: run: | pending=false ready=false + lookup_failed=false # 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 @@ -85,9 +83,15 @@ jobs: # 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" \ + # --paginate makes this N requests, so guard it too, and remember that a failure + # here means "unknown", never "nothing to release". + if ! 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')" + --jq '.[] | select(.pull_request.merged_at != null) | .number')"; then + echo "::warning::Could not list pending releases." + nums="" + lookup_failed=true + fi # 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 @@ -98,9 +102,10 @@ jobs: # 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 + --jq 'select(.merged_at != null and .base.ref == env.BASE) | .merge_commit_sha // empty')"; then echo "::warning::Could not read PR #${n}; skipping it." sha="" + lookup_failed=true continue fi if [ -n "$sha" ]; then @@ -109,7 +114,12 @@ jobs: fi done - if [ -z "$sha" ]; then + if [ -z "$sha" ] && [ "$lookup_failed" = true ]; then + # Unknown is not the same as nothing. Releasing on a guess is how a second + # Release PR lands on top of one that was never tagged. + pending=true + echo "::warning::Could not determine whether a release is pending on ${BASE}; standing down." + elif [ -z "$sha" ]; then echo "No pending release on ${BASE}." elif [ "$sha" != "$HEAD_SHA" ]; then # Reached when an earlier release run failed after the Release PR merged. @@ -124,6 +134,20 @@ jobs: 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" + # A warning annotation and a step summary are both only visible to someone who + # already opened the run. Tell the Release PR's subscribers once per stuck sha. + marker="" + seen="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${num}/comments" --paginate --jq '.[].body' || echo "")" + if ! printf '%s' "$seen" | grep -qF "$marker"; then + { + echo "$marker" + echo "This release is stuck: #${num} merged at \`${sha}\` and was never tagged, so no Release PR is 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 here by hand and release forward." + } > "${RUNNER_TEMP}/release-stuck.md" + gh pr comment "$num" --body-file "${RUNNER_TEMP}/release-stuck.md" \ + || echo "::warning::Could not comment on #${num}." + fi else pending=true ready=true From d6a2bf1756c83bd4438da3298ea4304df002b957 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Thu, 17 Sep 2026 13:23:33 +0200 Subject: [PATCH 3/3] ci: give the stuck-release comment an explicit repo --- .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 6bfa219..3d319b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,7 +145,7 @@ jobs: echo echo "Re-run the \`Release\` run for \`${sha}\`. If that commit is genuinely broken, remove the \`autorelease: pending\` label here by hand and release forward." } > "${RUNNER_TEMP}/release-stuck.md" - gh pr comment "$num" --body-file "${RUNNER_TEMP}/release-stuck.md" \ + gh pr comment "$num" --repo "$GITHUB_REPOSITORY" --body-file "${RUNNER_TEMP}/release-stuck.md" \ || echo "::warning::Could not comment on #${num}." fi else