name: Maintainer Approval # Gates merge on a maintainer's approval. The job *is* the required check: it # exits non-zero until a maintainer approves, and GitHub reports that pass/fail # as the `Maintainer Approval` status. No commit status is posted (a fork's token # is read-only, so a `gh api .../statuses` POST would 403), so the check is the # job result instead. # # Trigger is `pull_request_target`, so it runs from main with the base token # even for fork PRs: it isn't held behind the fork-PR-workflow approval gate # (reports immediately on open), and the PR-head copy never runs (a malicious PR # can't weaken the check). Safe because the job checks out nothing and runs no PR # code — it reads .github/MAINTAINER from main's tip (so a PR can't self-grant by # adding its author) and queries the API. # # `pull_request_target` doesn't fire on reviews, so maintainer-approval-rerun.yml # + -rerun-run.yml re-run this workflow on an approving review to flip it green. on: pull_request_target: types: [opened, reopened, synchronize, ready_for_review] permissions: contents: read concurrency: # Don't cancel in-progress: a run cancelled mid-flight leaves the check red. group: maintainer-approval-${{ github.event.pull_request.number }} cancel-in-progress: false jobs: approve: name: Maintainer Approval permissions: contents: read pull-requests: read runs-on: ubuntu-latest timeout-minutes: 3 steps: - name: Require maintainer approval env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} run: | # --- Load maintainers from .github/MAINTAINER at main's tip --- set +e CONTENT_B64=$(gh api "repos/$REPO/contents/.github/MAINTAINER?ref=main" --jq '.content' 2>/dev/null) RC=$? set -e if [[ $RC -ne 0 || -z "$CONTENT_B64" ]]; then echo "::error::.github/MAINTAINER not found on main" exit 1 fi CONTENT=$(echo "$CONTENT_B64" | base64 -d) # Strip comments/blanks to a space-separated list. `grep -v` exits 1 # on no matches; wrap with `|| true` so pipefail reaches the empty branch. MAINTAINERS=$(echo "$CONTENT" | sed -E 's/#.*$//' | tr -s '[:space:]' '\n' | { grep -v '^$' || true; } | tr '\n' ' ') MAINTAINERS_LC=$(echo "$MAINTAINERS" | tr '[:upper:]' '[:lower:]') if [[ -z "${MAINTAINERS_LC// /}" ]]; then echo "::error::.github/MAINTAINER on main has no entries" exit 1 fi AUTHOR=$(gh pr view "$PR" --repo "$REPO" --json author --jq '.author.login') AUTHOR_LC=$(echo "$AUTHOR" | tr '[:upper:]' '[:lower:]') # Case 1: author is a maintainer. for m in $MAINTAINERS_LC; do if [[ "$m" == "$AUTHOR_LC" ]]; then echo "Author @$AUTHOR is a maintainer." exit 0 fi done # Case 2: latest non-COMMENTED review state of some maintainer # is APPROVED. Matches GitHub's UI semantics (COMMENTED does # not supersede APPROVED; CHANGES_REQUESTED and DISMISSED do). APPROVERS=$(gh api "repos/$REPO/pulls/$PR/reviews" --paginate \ --jq '[.[] | select(.state != "COMMENTED")] | group_by(.user.login) | map(max_by(.submitted_at)) | .[] | select(.state == "APPROVED") | .user.login') for u in $APPROVERS; do u_lc=$(echo "$u" | tr '[:upper:]' '[:lower:]') for m in $MAINTAINERS_LC; do if [[ "$m" == "$u_lc" ]]; then echo "Approved by maintainer @$u." exit 0 fi done done # Case 3: nothing yet -- fail the check so merge stays blocked. echo "::error::Awaiting approval from a maintainer (one of: $MAINTAINERS)." exit 1