Auto-merge on Approval #1252
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto-merge on Approval | |
| # Evaluates a PR against three gates and manages labels accordingly: | |
| # 1. checks_passed — all status checks on the PR head are SUCCESS / NEUTRAL / SKIPPED | |
| # (no FAILURE / CANCELLED / TIMED_OUT and no PENDING entries). | |
| # 2. up_to_date — PR branch is not behind base (no "Update branch" needed). | |
| # 3. human_approved — at least one non-bot reviewer has APPROVED, and the overall | |
| # reviewDecision is not CHANGES_REQUESTED. | |
| # 4. images_reviewed — the `needs-image-review` label is absent. pr-review.yml | |
| # applies that label whenever a PR adds or modifies an image; | |
| # a maintainer clears it by hand after inspecting the files. | |
| # | |
| # Labels applied: | |
| # - update-branch when the PR is behind base | |
| # - needs-human-review when no human approval (or changes requested) | |
| # - ready-for-auto-merge when all four gates pass; on this state the workflow | |
| # also performs a squash merge with branch deletion. | |
| # | |
| # The workflow runs on PR events, review events, and check_suite completions, so it | |
| # re-evaluates whenever any of the inputs change. | |
| on: | |
| pull_request_target: | |
| types: [opened, synchronize, reopened, labeled, unlabeled, ready_for_review] | |
| pull_request_review: | |
| types: [submitted, dismissed, edited] | |
| check_suite: | |
| types: [completed] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| checks: read | |
| statuses: read | |
| actions: read | |
| concurrency: | |
| group: auto-merge-${{ github.event.pull_request.number || github.event.check_suite.pull_requests[0].number || github.run_id }} | |
| cancel-in-progress: false | |
| jobs: | |
| evaluate-and-merge: | |
| name: Evaluate PR gates and merge when ready | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Resolve PR number | |
| id: pr | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| PR_NUMBER="" | |
| PR_AUTHOR="" | |
| case "${{ github.event_name }}" in | |
| pull_request_target|pull_request_review) | |
| PR_NUMBER="${{ github.event.pull_request.number }}" | |
| PR_AUTHOR="${{ github.event.pull_request.user.login }}" | |
| ;; | |
| check_suite) | |
| PR_NUMBER=$(jq -r '.check_suite.pull_requests[0].number // empty' "$GITHUB_EVENT_PATH") | |
| PR_AUTHOR=$(jq -r '.check_suite.pull_requests[0].user.login // empty' "$GITHUB_EVENT_PATH") | |
| ;; | |
| esac | |
| if [ -z "$PR_NUMBER" ]; then | |
| echo "No PR associated with this event — skipping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| elif [ "$PR_AUTHOR" = "dependabot[bot]" ]; then | |
| # Dependabot-authored PRs run under a read-only token by GitHub policy; | |
| # any gh api call would 404/403. Skip cleanly here, before we try. | |
| # (Dependabot uses its own auto-merge via `dependabot.yml` / repo settings.) | |
| echo "PR #$PR_NUMBER is authored by Dependabot — skipping (Dependabot manages its own merge)." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Resolved PR #$PR_NUMBER (author=$PR_AUTHOR)" | |
| echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Ensure managed labels exist | |
| if: steps.pr.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| ensure_label() { | |
| local name="$1"; local color="$2"; local desc="$3" | |
| gh api -X POST "/repos/$REPO/labels" \ | |
| -f name="$name" -f color="$color" -f description="$desc" \ | |
| >/dev/null 2>&1 || true | |
| } | |
| ensure_label "update-branch" "fbca04" "PR branch is behind base — please update" | |
| ensure_label "needs-human-review" "0075ca" "Awaiting human approval" | |
| ensure_label "ready-for-auto-merge" "0e8a16" "All gates green — will be auto-merged" | |
| ensure_label "checks-in-progress" "ededed" "One or more required status checks are still running on this PR" | |
| - name: Evaluate gates, update labels, and merge if ready | |
| if: steps.pr.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ steps.pr.outputs.number }} | |
| run: | | |
| set -euo pipefail | |
| PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json \ | |
| number,isDraft,state,baseRefName,headRefName,mergeStateStatus,mergeable,reviewDecision,reviews,statusCheckRollup,labels,author) | |
| STATE=$(echo "$PR_JSON" | jq -r '.state') | |
| IS_DRAFT=$(echo "$PR_JSON" | jq -r '.isDraft') | |
| AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login') | |
| BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName') | |
| HEAD_REF=$(echo "$PR_JSON" | jq -r '.headRefName') | |
| MERGE_STATE=$(echo "$PR_JSON" | jq -r '.mergeStateStatus') | |
| MERGEABLE=$(echo "$PR_JSON" | jq -r '.mergeable') | |
| REVIEW_DECISION=$(echo "$PR_JSON" | jq -r '.reviewDecision') | |
| echo "PR #$PR_NUMBER author=$AUTHOR state=$STATE draft=$IS_DRAFT" | |
| echo "mergeStateStatus=$MERGE_STATE mergeable=$MERGEABLE reviewDecision=$REVIEW_DECISION" | |
| # Skip closed / merged / draft / Dependabot PRs. | |
| if [ "$STATE" != "OPEN" ] || [ "$IS_DRAFT" = "true" ] || [ "$AUTHOR" = "dependabot[bot]" ]; then | |
| echo "PR is not eligible (closed, draft, or Dependabot) — skipping." | |
| exit 0 | |
| fi | |
| # ── Gate 1: checks_passed ────────────────────────────────── | |
| # Any FAILURE/CANCELLED/TIMED_OUT/ACTION_REQUIRED/STALE conclusion fails. | |
| # Any null/IN_PROGRESS/QUEUED conclusion or PENDING state means "still running". | |
| CHECKS_FAILED=$(echo "$PR_JSON" | jq ' | |
| [.statusCheckRollup[]? | |
| | (.conclusion // .state // "PENDING") | |
| | ascii_upcase | |
| | select(. == "FAILURE" or . == "CANCELLED" or . == "TIMED_OUT" | |
| or . == "ACTION_REQUIRED" or . == "STALE" or . == "ERROR") | |
| ] | length') | |
| CHECKS_PENDING=$(echo "$PR_JSON" | jq ' | |
| [.statusCheckRollup[]? | |
| | (.conclusion // .state // "PENDING") | |
| | ascii_upcase | |
| | select(. == "PENDING" or . == "IN_PROGRESS" or . == "QUEUED" or . == "WAITING") | |
| ] | length') | |
| if [ "$CHECKS_FAILED" -gt 0 ] || [ "$CHECKS_PENDING" -gt 0 ]; then | |
| CHECKS_PASSED=false | |
| else | |
| CHECKS_PASSED=true | |
| fi | |
| # ── Gate 2: up_to_date ───────────────────────────────────── | |
| if [ "$MERGE_STATE" = "BEHIND" ]; then | |
| UP_TO_DATE=false | |
| else | |
| UP_TO_DATE=true | |
| fi | |
| # ── Gate 3: human_approved ───────────────────────────────── | |
| # Trust GitHub's own reviewDecision instead of re-implementing the | |
| # logic locally. GitHub already accounts for CODEOWNERS, dismissals, | |
| # required_approving_review_count, etc. — and our previous local | |
| # heuristic (just counting non-bot APPROVED reviews) could disagree | |
| # with branch protection (e.g. on repos with require_code_owner_reviews=true), | |
| # which led the script to attempt `gh pr merge --auto` on PRs that | |
| # GitHub still considered REVIEW_REQUIRED and fail with | |
| # "User is not authorized for this protected branch". | |
| HUMAN_APPROVALS=$(echo "$PR_JSON" | jq ' | |
| [.reviews[]? | |
| | select(.state == "APPROVED") | |
| | select((.author.login // "") | endswith("[bot]") | not) | |
| ] | length') | |
| if [ "$REVIEW_DECISION" = "APPROVED" ] && [ "$HUMAN_APPROVALS" -gt 0 ]; then | |
| HUMAN_APPROVED=true | |
| else | |
| HUMAN_APPROVED=false | |
| fi | |
| # Generated registry PRs have elevated write provenance. Fail closed | |
| # unless the target branch currently requires CODEOWNER review; | |
| # reviewDecision=APPROVED then proves GitHub applied that protection. | |
| REGISTRY_PROTECTION_OK=true | |
| if [[ "$HEAD_REF" == chore/registry-refresh-* ]]; then | |
| PROTECTION=$(gh api \ | |
| "/repos/$REPO/branches/$BASE_REF/protection/required_pull_request_reviews" \ | |
| 2>/dev/null || true) | |
| if [ -z "$PROTECTION" ] || \ | |
| [ "$(echo "$PROTECTION" | jq -r '.require_code_owner_reviews // false')" != "true" ]; then | |
| REGISTRY_PROTECTION_OK=false | |
| HUMAN_APPROVED=false | |
| echo "::warning::Generated registry PR cannot auto-merge because CODEOWNER review protection could not be verified on $BASE_REF." | |
| fi | |
| fi | |
| # ── Gate 4: images_reviewed ──────────────────────────────── | |
| # Applied by pr-review.yml on any PR touching an image, and cleared | |
| # by a maintainer once they have actually looked at the files. A new | |
| # commit re-applies it, so approval never carries over to images the | |
| # reviewer has not seen. | |
| if echo "$PR_JSON" | jq -e '.labels[]? | select(.name == "needs-image-review")' >/dev/null; then | |
| IMAGES_REVIEWED=false | |
| else | |
| IMAGES_REVIEWED=true | |
| fi | |
| echo "Gates: checks_passed=$CHECKS_PASSED up_to_date=$UP_TO_DATE human_approved=$HUMAN_APPROVED images_reviewed=$IMAGES_REVIEWED registry_protection=$REGISTRY_PROTECTION_OK" | |
| echo " (checks_failed=$CHECKS_FAILED pending=$CHECKS_PENDING human_approvals=$HUMAN_APPROVALS reviewDecision=$REVIEW_DECISION)" | |
| # ── Label management ─────────────────────────────────────── | |
| add_label() { | |
| gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$1" >/dev/null 2>&1 || true | |
| } | |
| remove_label() { | |
| gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$1" >/dev/null 2>&1 || true | |
| } | |
| if [ "$UP_TO_DATE" = "false" ]; then add_label "update-branch"; else remove_label "update-branch"; fi | |
| if [ "$HUMAN_APPROVED" = "false" ]; then add_label "needs-human-review"; else remove_label "needs-human-review"; fi | |
| # checks-in-progress: any required check is still PENDING / IN_PROGRESS / QUEUED. | |
| # Cleared as soon as every check has reported (pass or fail). | |
| if [ "$CHECKS_PENDING" -gt 0 ]; then add_label "checks-in-progress"; else remove_label "checks-in-progress"; fi | |
| if [ "$CHECKS_PASSED" = "true" ] && [ "$UP_TO_DATE" = "true" ] && [ "$HUMAN_APPROVED" = "true" ] && [ "$IMAGES_REVIEWED" = "true" ] && [ "$REGISTRY_PROTECTION_OK" = "true" ]; then | |
| add_label "ready-for-auto-merge" | |
| # Also ensure the PR isn't blocked by merge conflicts. | |
| if [ "$MERGEABLE" != "MERGEABLE" ]; then | |
| echo "::warning::All gates pass but PR is not in a MERGEABLE state ($MERGEABLE) — leaving for manual review." | |
| exit 0 | |
| fi | |
| echo "All gates green — enabling auto-merge (squash)." | |
| # IMPORTANT: enabling auto-merge can fail for many benign reasons | |
| # (user not authorized to push to the protected branch, auto-merge | |
| # not enabled in repo settings, PR not eligible, etc.). This | |
| # workflow is a *required* status check, so a non-zero exit here | |
| # would mark the check FAILED and block the PR forever — creating | |
| # an approval/merge-retry loop. Treat the failure as advisory. | |
| if ! gh pr merge "$PR_NUMBER" --repo "$REPO" --auto --squash --delete-branch; then | |
| echo "::warning::Could not enable auto-merge on PR #$PR_NUMBER. A maintainer with admin/bypass rights will need to merge it manually." | |
| fi | |
| else | |
| remove_label "ready-for-auto-merge" | |
| if [ "$IMAGES_REVIEWED" = "false" ]; then | |
| echo "Blocked: this PR changes image files and still carries the needs-image-review label." | |
| fi | |
| echo "Not ready for auto-merge yet." | |
| fi |