95 lines
3.9 KiB
YAML
95 lines
3.9 KiB
YAML
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
|