diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11d6874..3d319b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,10 @@ jobs: release-pr: name: Release PR needs: detect - if: 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: @@ -55,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 }} @@ -70,28 +75,51 @@ jobs: run: | pending=false ready=false + lookup_failed=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. + # --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')"; 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 + # 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(.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 num="$n" break 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. @@ -99,6 +127,27 @@ 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" + # 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" --repo "$GITHUB_REPOSITORY" --body-file "${RUNNER_TEMP}/release-stuck.md" \ + || echo "::warning::Could not comment on #${num}." + fi else pending=true ready=true