This is an automated email from the ASF dual-hosted git repository.
400Ping pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/mahout.git
The following commit(s) were added to refs/heads/main by this push:
new 317756ddd [CI] Require a committer approval before merging PRs, route
Discussions to dev@ (#1494)
317756ddd is described below
commit 317756ddd7e5f82f1ef071c96abba6674347401d
Author: Jie-Kai Chang <[email protected]>
AuthorDate: Mon Sep 21 23:21:05 2026 +0800
[CI] Require a committer approval before merging PRs, route Discussions to
dev@ (#1494)
Signed-off-by: 400Ping <[email protected]>
---
.asf.yaml | 13 +-
.claude/launch.json | 18 --
.github/pmc-members.txt | 42 ++++
.github/scripts/check_pr_approval.py | 240 ++++++++++++++++++++++
.github/workflows/pr-approval-check.yml | 66 ++++++
docs/community/pr-policy-and-review-guidelines.md | 12 +-
6 files changed, 371 insertions(+), 20 deletions(-)
diff --git a/.asf.yaml b/.asf.yaml
index fbc1e54a1..1bfb5ec67 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -59,6 +59,17 @@ github:
main:
required_linear_history: true
required_signatures: false
+ # PR policy (docs/community/pr-policy-and-review-guidelines.md): a PR
needs
+ # at least one approving review from a committer before it can be merged,
+ # unless it was opened by a PMC member (.github/pmc-members.txt).
+ # GitHub's own required_approving_review_count (as used by Airflow)
cannot
+ # exempt PMC members, so the rule is enforced through this required
status
+ # check produced by .github/workflows/pr-approval-check.yml.
+ required_status_checks:
+ strict: false
+ checks:
+ - context: "Committer approval"
+ app_id: 15368 # GitHub Actions
environments:
pypi:
required_reviewers:
@@ -81,4 +92,4 @@ notifications:
commits: [email protected]
issues: [email protected]
pullrequests: [email protected]
- discussions: [email protected]
+ discussions: [email protected]
diff --git a/.claude/launch.json b/.claude/launch.json
deleted file mode 100644
index ba6b3530e..000000000
--- a/.claude/launch.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "version": "0.0.1",
- "configurations": [
- {
- "name": "website-serve",
- "runtimeExecutable": "/usr/local/bin/node",
- "runtimeArgs": [
- "node_modules/.bin/docusaurus",
- "serve",
- "--port",
- "3001",
- "--no-open"
- ],
- "cwd": "website",
- "port": 3001
- }
- ]
-}
\ No newline at end of file
diff --git a/.github/pmc-members.txt b/.github/pmc-members.txt
new file mode 100644
index 000000000..671dfbcca
--- /dev/null
+++ b/.github/pmc-members.txt
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# GitHub usernames of current Apache Mahout PMC members.
+#
+# Pull requests opened by these users are exempt from the
+# "at least one committer approval" rule enforced by
+# .github/workflows/pr-approval-check.yml (see
+# docs/community/pr-policy-and-review-guidelines.md).
+#
+# The authoritative PMC roster is LDAP
(https://whimsy.apache.org/roster/pmc/mahout,
+# public copy at https://whimsy.apache.org/public/public_ldap_projects.json
under
+# projects.mahout.owners). Only PMC members whose GitHub account is linked to
+# their ASF id (https://gitbox.apache.org/boxer/) can merge; the others are
listed
+# for completeness. Keep one username per line, case-insensitive.
+#
+# GitHub username # ASF id
+andrewmusselman # akm
+andrewpalumbo # apalumbo
+guan404ming # guanmingchiu
+MaineC # isabel
+400Ping # jiekaichang
+rich7420 # richhuang
+sscdotopen # ssc
+magsol # squinn
+sslavic # sslavic
+rawkintrevo # rawkintrevo
+# No GitHub account on record (cannot merge until linked): jmannix, pranjan,
robinanil
diff --git a/.github/scripts/check_pr_approval.py
b/.github/scripts/check_pr_approval.py
new file mode 100644
index 000000000..a0823bf2e
--- /dev/null
+++ b/.github/scripts/check_pr_approval.py
@@ -0,0 +1,240 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""Check that a pull request has at least one approving review from a
committer.
+
+This backs the ``Committer approval`` required status check on ``main``
+(see ``.github/workflows/pr-approval-check.yml`` and ``.asf.yaml``).
+
+Rules:
+
+* A pull request opened by a PMC member (``.github/pmc-members.txt``) is
exempt.
+* Otherwise, at least one reviewer whose *latest* review is ``APPROVED`` must
have
+ write access to the repository. Per ASF policy every committer has write
access,
+ so "has write access" is used as the definition of "is a committer".
+* Following GitHub's own semantics, a ``COMMENTED`` review does not revoke an
+ earlier approval, while ``CHANGES_REQUESTED`` or a dismissal does.
+
+Only the Python standard library is used so the script runs in CI and locally:
+
+ GITHUB_TOKEN=$(gh auth token) python .github/scripts/check_pr_approval.py
\\
+ --repo apache/mahout --pr 1234
+
+Exit status is 0 when the PR may be merged and 1 otherwise.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+API_URL = os.environ.get("GITHUB_API_URL", "https://api.github.com")
+DEFAULT_PMC_FILE = Path(__file__).resolve().parents[1] / "pmc-members.txt"
+# Review states that change a reviewer's standing. COMMENTED is deliberately
+# excluded: on GitHub a comment-only review leaves a previous approval in
place.
+DECISIVE_STATES = {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"}
+
+
+class GitHubError(RuntimeError):
+ """Raised when the GitHub API returns an unexpected response."""
+
+
+def _api(token: str, path: str, params: dict[str, str] | None = None) -> Any:
+ url = f"{API_URL}{path}"
+ if params:
+ url = f"{url}?{urllib.parse.urlencode(params)}"
+ request = urllib.request.Request(
+ url,
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "apache-mahout-pr-approval-check",
+ },
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ return json.load(response)
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace")
+ raise GitHubError(f"GET {path} -> HTTP {exc.code}: {body}") from exc
+
+
+def _paginate(token: str, path: str) -> list[dict[str, Any]]:
+ items: list[dict[str, Any]] = []
+ page = 1
+ while True:
+ chunk = _api(token, path, {"per_page": "100", "page": str(page)})
+ items.extend(chunk)
+ if len(chunk) < 100:
+ return items
+ page += 1
+
+
+def load_pmc_members(path: Path) -> set[str]:
+ """Return the lower-cased GitHub usernames listed in ``path``.
+
+ Blank lines and ``#`` comments (full-line or trailing) are ignored.
+ """
+ members: set[str] = set()
+ if not path.is_file():
+ return members
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
+ line = raw_line.split("#", 1)[0].strip()
+ if line:
+ members.add(line.lower())
+ return members
+
+
+def latest_decisive_reviews(reviews: list[dict[str, Any]]) -> dict[str,
dict[str, Any]]:
+ """Map each reviewer login (lower-cased key) to their latest decisive
review."""
+ latest: dict[str, dict[str, Any]] = {}
+ # The API returns reviews in chronological order; sort defensively anyway.
+ for review in sorted(reviews, key=lambda r: (r.get("submitted_at") or "",
r["id"])):
+ if review.get("state") not in DECISIVE_STATES:
+ continue
+ user = review.get("user") or {}
+ login = user.get("login")
+ if not login or user.get("type") == "Bot":
+ continue
+ latest[login.lower()] = review
+ return latest
+
+
+def has_write_access(token: str, repo: str, login: str) -> bool:
+ path =
f"/repos/{repo}/collaborators/{urllib.parse.quote(login)}/permission"
+ data = _api(token, path)
+ permissions = (data.get("user") or {}).get("permissions") or {}
+ if "push" in permissions:
+ return bool(permissions["push"])
+ return data.get("permission") in {"write", "admin"}
+
+
+def check(
+ token: str, repo: str, pr_number: int, pmc_members: set[str]
+) -> tuple[bool, list[str]]:
+ """Evaluate the rule. Returns ``(ok, log_lines)``."""
+ lines: list[str] = []
+ pr = _api(token, f"/repos/{repo}/pulls/{pr_number}")
+ author = (pr.get("user") or {}).get("login", "")
+ lines.append(
+ f"PR #{pr_number} by @{author} -> {pr['base']['ref']} "
+ f"(head {pr['head']['sha'][:7]})"
+ )
+
+ if author.lower() in pmc_members:
+ lines.append(
+ f"@{author} is a PMC member: exempt from the committer-approval
rule."
+ )
+ return True, lines
+
+ reviews = _paginate(token, f"/repos/{repo}/pulls/{pr_number}/reviews")
+ approvers = [
+ review["user"]["login"]
+ for login, review in latest_decisive_reviews(reviews).items()
+ if review["state"] == "APPROVED" and login != author.lower()
+ ]
+ if not approvers:
+ lines.append("No approving reviews found.")
+ return False, lines
+
+ committer_approvers: list[str] = []
+ for login in sorted(approvers, key=str.lower):
+ if has_write_access(token, repo, login):
+ committer_approvers.append(login)
+ lines.append(f"Approved by @{login} (committer).")
+ else:
+ lines.append(
+ f"Approved by @{login} (not a committer; does not satisfy the
rule)."
+ )
+
+ if committer_approvers:
+ return True, lines
+ lines.append("No approving review from a committer.")
+ return False, lines
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter
+ )
+ parser.add_argument(
+ "--repo",
+ default=os.environ.get("GITHUB_REPOSITORY"),
+ help="owner/name (default: $GITHUB_REPOSITORY)",
+ )
+ parser.add_argument(
+ "--pr",
+ type=int,
+ default=os.environ.get("PR_NUMBER"),
+ help="pull request number (default: $PR_NUMBER)",
+ )
+ parser.add_argument(
+ "--pmc-file",
+ type=Path,
+ default=DEFAULT_PMC_FILE,
+ help=f"PMC member list (default: {DEFAULT_PMC_FILE})",
+ )
+ args = parser.parse_args(argv)
+
+ token = os.environ.get("GITHUB_TOKEN")
+ if not token or not args.repo or args.pr is None:
+ parser.error(
+ "GITHUB_TOKEN, --repo/$GITHUB_REPOSITORY and --pr/$PR_NUMBER are
required"
+ )
+
+ pmc_members = load_pmc_members(args.pmc_file)
+ if not pmc_members:
+ print(
+ f"::warning::No PMC members loaded from {args.pmc_file}; "
+ "no author is exempt."
+ )
+
+ try:
+ ok, lines = check(token, args.repo, int(args.pr), pmc_members)
+ except GitHubError as exc:
+ print(f"::error::{exc}")
+ return 1
+
+ if ok:
+ verdict = "PASS: at least one committer approval (or PMC author)."
+ else:
+ verdict = "FAIL: needs at least one approving review from a committer."
+ lines.append(verdict)
+ print("\n".join(lines))
+
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
+ if summary_path:
+ with open(summary_path, "a", encoding="utf-8") as summary:
+ summary.write("### Committer approval\n\n")
+ summary.writelines(f"- {line}\n" for line in lines)
+ if not ok:
+ print(
+ f"::error::{verdict} "
+ "See docs/community/pr-policy-and-review-guidelines.md."
+ )
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/workflows/pr-approval-check.yml
b/.github/workflows/pr-approval-check.yml
new file mode 100644
index 000000000..819ab5452
--- /dev/null
+++ b/.github/workflows/pr-approval-check.yml
@@ -0,0 +1,66 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Enforces the PR policy (docs/community/pr-policy-and-review-guidelines.md):
+# a pull request needs at least one approving review from a committer before
+# it can be merged, unless it was opened by a PMC member
+# (.github/pmc-members.txt). The "Committer approval" job is a required status
+# check on main via .asf.yaml, mirroring Airflow's
+# required_approving_review_count: 1 but with the PMC exemption.
+#
+name: PR Approval Check
+
+on:
+ # Runs with the base repository's workflow definition, also for fork PRs.
+ pull_request_target:
+ types: [opened, synchronize, reopened, ready_for_review]
+ # Re-evaluate whenever a review is submitted, edited or dismissed.
+ pull_request_review:
+ types: [submitted, edited, dismissed]
+
+permissions:
+ contents: read
+ pull-requests: read
+
+concurrency:
+ group: pr-approval-check-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ committer-approval:
+ # This job name is the status check context referenced in .asf.yaml.
+ name: Committer approval
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ # Always take the script and the PMC list from the base branch so a PR
+ # cannot change the rule it is being checked against. No code from the
+ # PR head is executed.
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ github.event.pull_request.base.ref }}
+ persist-credentials: false
+ sparse-checkout: |
+ .github/pmc-members.txt
+ .github/scripts/check_pr_approval.py
+ sparse-checkout-cone-mode: false
+
+ - name: Check for a committer approval
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: python3 .github/scripts/check_pr_approval.py
diff --git a/docs/community/pr-policy-and-review-guidelines.md
b/docs/community/pr-policy-and-review-guidelines.md
index 91949825c..7c4e5f5c8 100644
--- a/docs/community/pr-policy-and-review-guidelines.md
+++ b/docs/community/pr-policy-and-review-guidelines.md
@@ -74,9 +74,19 @@ For non-trivial code contributions, contributors are
expected to have an ASF ICL
When deciding whether to merge, committers should verify all of the following:
- Required CI checks are green.
-- At least one committer approves.
+- At least one committer approves (enforced automatically, see below).
- All blocking comments are resolved.
+#### Enforced Committer Approval
+
+The `Committer approval` status check
([`pr-approval-check.yml`](https://github.com/apache/mahout/blob/main/.github/workflows/pr-approval-check.yml))
is required on `main` and re-runs whenever a review is submitted, edited, or
dismissed:
+
+- The PR must have at least one reviewer whose latest review is an approval
and who has write access to the repository (i.e. a committer). Approvals from
non-committers are welcome but do not satisfy the check.
+- Following GitHub semantics, a comment-only review keeps an earlier approval
in place; "Request changes" or dismissing the review removes it.
+- PRs opened by PMC members are exempt. The list of PMC GitHub accounts lives
in
[`.github/pmc-members.txt`](https://github.com/apache/mahout/blob/main/.github/pmc-members.txt)
and must be updated when the PMC roster changes.
+
+The two-approval expectation for high-impact changes below is not enforced by
tooling and remains a committer responsibility.
+
The following changes should receive at least two committer approvals and
prior public discussion (issue or `dev@` thread):
- API or behavior-breaking changes.