diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d694bb6..bbdb0a3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,12 +20,21 @@ 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 + 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 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')) + (github.ref_name == 'main' || endsWith(github.ref_name, '.x')) && + needs.detect.outputs.pending != 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -42,8 +51,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: >- @@ -54,30 +64,81 @@ jobs: permissions: contents: read pull-requests: read + issues: 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, 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 + # 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 + 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." + { + 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 + 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 +147,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 +157,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 +168,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: 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